1
0
Fork 0
mirror of synced 2024-09-08 13:41:09 +12:00

Merge remote-tracking branch 'origin/develop' into bug/budi-5901-usage-quota-document-conflicts-can-cause

This commit is contained in:
adrinr 2023-03-01 13:31:56 +01:00
commit 04566dbabd
49 changed files with 2997 additions and 364 deletions

View file

@ -1,5 +1,5 @@
{
"version": "2.3.18-alpha.14",
"version": "2.3.18-alpha.17",
"npmClient": "yarn",
"packages": [
"packages/*"

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/backend-core",
"version": "2.3.18-alpha.14",
"version": "2.3.18-alpha.17",
"description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
@ -24,7 +24,7 @@
"dependencies": {
"@budibase/nano": "10.1.1",
"@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/types": "2.3.18-alpha.14",
"@budibase/types": "2.3.18-alpha.17",
"@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0",

View file

@ -154,11 +154,29 @@ export async function getGoogleConfig(): Promise<
GoogleInnerConfig | undefined
> {
const config = await getGoogleConfigDoc()
if (config) {
return config.config
return config?.config
}
export async function getGoogleDatasourceConfig(): Promise<
GoogleInnerConfig | undefined
> {
if (!env.SELF_HOSTED) {
// always use the env vars in cloud
return getDefaultGoogleConfig()
}
// Use google fallback configuration from env variables
// prefer the config in self-host
let config = await getGoogleConfig()
// fallback to env vars
if (!config || !config.activated) {
config = getDefaultGoogleConfig()
}
return config
}
export function getDefaultGoogleConfig(): GoogleInnerConfig | undefined {
if (environment.GOOGLE_CLIENT_ID && environment.GOOGLE_CLIENT_SECRET) {
return {
clientID: environment.GOOGLE_CLIENT_ID!,

View file

@ -12,7 +12,8 @@ type Passport = {
}
async function fetchGoogleCreds() {
const config = await configs.getGoogleConfig()
let config = await configs.getGoogleDatasourceConfig()
if (!config) {
throw new Error("No google configuration found")
}

View file

@ -5,6 +5,7 @@ import {
generateAppUserID,
queryGlobalView,
UNICODE_MAX,
directCouchFind,
} from "./db"
import { BulkDocsResponse, User } from "@budibase/types"
import { getGlobalDB } from "./context"
@ -101,6 +102,7 @@ export const searchGlobalUsersByApp = async (
})
params.startkey = opts && opts.startkey ? opts.startkey : params.startkey
let response = await queryGlobalView(ViewName.USER_BY_APP, params)
if (!response) {
response = []
}
@ -111,6 +113,45 @@ export const searchGlobalUsersByApp = async (
return users
}
/*
Return any user who potentially has access to the application
Admins, developers and app users with the explicitly role.
*/
export const searchGlobalUsersByAppAccess = async (appId: any, opts: any) => {
const roleSelector = `roles.${appId}`
let orQuery: any[] = [
{
"builder.global": true,
},
{
"admin.global": true,
},
]
if (appId) {
const roleCheck = {
[roleSelector]: {
$exists: true,
},
}
orQuery.push(roleCheck)
}
let searchOptions = {
selector: {
$or: orQuery,
_id: {
$regex: "^us_",
},
},
limit: opts?.limit || 50,
}
const resp = await directCouchFind(context.getGlobalDBName(), searchOptions)
return resp?.rows
}
export const getGlobalUserByAppPage = (appId: string, user: User) => {
if (!user) {
return

View file

@ -1,7 +1,7 @@
{
"name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.",
"version": "2.3.18-alpha.14",
"version": "2.3.18-alpha.17",
"license": "MPL-2.0",
"svelte": "src/index.js",
"module": "dist/bbui.es.js",
@ -38,7 +38,7 @@
],
"dependencies": {
"@adobe/spectrum-css-workflow-icons": "1.2.1",
"@budibase/string-templates": "2.3.18-alpha.14",
"@budibase/string-templates": "2.3.18-alpha.17",
"@spectrum-css/accordion": "3.0.24",
"@spectrum-css/actionbutton": "1.0.1",
"@spectrum-css/actiongroup": "1.0.1",

View file

@ -1,6 +1,9 @@
<script>
import "@spectrum-css/actionbutton/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
import Tooltip from "../Tooltip/Tooltip.svelte"
import { fade } from "svelte/transition"
const dispatch = createEventDispatcher()
export let quiet = false
@ -13,6 +16,9 @@
export let active = false
export let fullWidth = false
export let noPadding = false
export let tooltip = ""
let showTooltip = false
function longPress(element) {
if (!longPressable) return
@ -35,42 +41,54 @@
}
</script>
<button
use:longPress
class:spectrum-ActionButton--quiet={quiet}
class:spectrum-ActionButton--emphasized={emphasized}
class:is-selected={selected}
class:noPadding
class:fullWidth
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
class:active
{disabled}
on:longPress
on:click|preventDefault
<span
class="btn-wrap"
on:mouseover={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
on:focus={() => (showTooltip = true)}
>
{#if longPressable}
<svg
class="spectrum-Icon spectrum-UIIcon-CornerTriangle100 spectrum-ActionButton-hold"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-CornerTriangle100" />
</svg>
{/if}
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--size{size}"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
{#if $$slots}
<span class="spectrum-ActionButton-label"><slot /></span>
{/if}
</button>
<button
use:longPress
class:spectrum-ActionButton--quiet={quiet}
class:spectrum-ActionButton--emphasized={emphasized}
class:is-selected={selected}
class:noPadding
class:fullWidth
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
class:active
{disabled}
on:longPress
on:click|preventDefault
>
{#if longPressable}
<svg
class="spectrum-Icon spectrum-UIIcon-CornerTriangle100 spectrum-ActionButton-hold"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-CornerTriangle100" />
</svg>
{/if}
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--size{size}"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
{#if $$slots}
<span class="spectrum-ActionButton-label"><slot /></span>
{/if}
{#if tooltip && showTooltip}
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
<Tooltip textWrapping direction="bottom" text={tooltip} />
</div>
{/if}
</button>
</span>
<style>
.fullWidth {
@ -98,4 +116,14 @@
.is-selected:not(.emphasized) .spectrum-Icon {
color: var(--spectrum-global-color-gray-900);
}
.tooltip {
position: absolute;
pointer-events: none;
left: 50%;
top: calc(100% + 4px);
width: 100vw;
max-width: 150px;
transform: translateX(-50%);
text-align: center;
}
</style>

View file

@ -24,6 +24,7 @@
export let getOptionLabel = option => option
export let getOptionValue = option => option
export let getOptionIcon = () => null
export let useOptionIconImage = false
export let getOptionColour = () => null
export let open = false
export let readonly = false
@ -33,6 +34,9 @@
export let sort = false
export let fetchTerm = null
export let customPopoverHeight
export let align = "left"
export let footer = null
const dispatch = createEventDispatcher()
let searchTerm = null
@ -131,7 +135,7 @@
<Popover
anchor={button}
align="left"
align={align || "left"}
bind:this={popover}
{open}
on:close={() => (open = false)}
@ -186,7 +190,16 @@
>
{#if getOptionIcon(option, idx)}
<span class="option-extra icon">
<Icon size="S" name={getOptionIcon(option, idx)} />
{#if useOptionIconImage}
<img
src={getOptionIcon(option, idx)}
alt="icon"
width="15"
height="15"
/>
{:else}
<Icon size="S" name={getOptionIcon(option, idx)} />
{/if}
</span>
{/if}
{#if getOptionColour(option, idx)}
@ -208,6 +221,12 @@
{/each}
{/if}
</ul>
{#if footer}
<div class="footer">
{footer}
</div>
{/if}
</div>
</Popover>
@ -284,4 +303,11 @@
.popover-content :global(.spectrum-Search .spectrum-Textfield-icon) {
top: 9px;
}
.footer {
padding: 4px 12px 12px 12px;
font-style: italic;
max-width: 170px;
font-size: 12px;
}
</style>

View file

@ -11,6 +11,7 @@
export let getOptionLabel = option => option
export let getOptionValue = option => option
export let getOptionIcon = () => null
export let useOptionIconImage = false
export let getOptionColour = () => null
export let isOptionEnabled
export let readonly = false
@ -18,6 +19,8 @@
export let autoWidth = false
export let autocomplete = false
export let sort = false
export let align
export let footer = null
const dispatch = createEventDispatcher()
@ -41,7 +44,7 @@
const getFieldText = (value, options, placeholder) => {
// Always use placeholder if no value
if (value == null || value === "") {
return placeholder || "Choose an option"
return placeholder !== false ? "Choose an option" : ""
}
return getFieldAttribute(getOptionLabel, value, options)
@ -66,15 +69,18 @@
{fieldColour}
{options}
{autoWidth}
{align}
{footer}
{getOptionLabel}
{getOptionValue}
{getOptionIcon}
{useOptionIconImage}
{getOptionColour}
{isOptionEnabled}
{autocomplete}
{sort}
isPlaceholder={value == null || value === ""}
placeholderOption={placeholder}
placeholderOption={placeholder === false ? null : placeholder}
isOptionSelected={option => option === value}
onSelectOption={selectOption}
/>

View file

@ -14,6 +14,7 @@
export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value")
export let getOptionIcon = option => option?.icon
export let useOptionIconImage = false
export let getOptionColour = option => option?.colour
export let isOptionEnabled
export let quiet = false
@ -22,6 +23,8 @@
export let tooltip = ""
export let autocomplete = false
export let customPopoverHeight
export let align
export let footer = null
const dispatch = createEventDispatcher()
const onChange = e => {
@ -48,10 +51,13 @@
{placeholder}
{autoWidth}
{sort}
{align}
{footer}
{getOptionLabel}
{getOptionValue}
{getOptionIcon}
{getOptionColour}
{useOptionIconImage}
{isOptionEnabled}
{autocomplete}
{customPopoverHeight}

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "2.3.18-alpha.14",
"version": "2.3.18-alpha.17",
"license": "GPL-3.0",
"private": true,
"scripts": {
@ -58,10 +58,10 @@
}
},
"dependencies": {
"@budibase/bbui": "2.3.18-alpha.14",
"@budibase/client": "2.3.18-alpha.14",
"@budibase/frontend-core": "2.3.18-alpha.14",
"@budibase/string-templates": "2.3.18-alpha.14",
"@budibase/bbui": "2.3.18-alpha.17",
"@budibase/client": "2.3.18-alpha.17",
"@budibase/frontend-core": "2.3.18-alpha.17",
"@budibase/string-templates": "2.3.18-alpha.17",
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",

View file

@ -72,6 +72,8 @@ const INITIAL_FRONTEND_STATE = {
// onboarding
onboarding: false,
tourNodes: null,
builderSidePanel: false,
}
export const getFrontendStore = () => {

View file

@ -192,13 +192,13 @@
editableColumn.name = originalName
}
function deleteColumn() {
async function deleteColumn() {
try {
editableColumn.name = deleteColName
if (editableColumn.name === $tables.selected.primaryDisplay) {
notifications.error("You cannot delete the display column")
} else {
tables.deleteField(editableColumn)
await tables.deleteField(editableColumn)
notifications.success(`Column ${editableColumn.name} deleted.`)
confirmDeleteDialog.hide()
hide()

View file

@ -11,16 +11,24 @@
export let quiet = false
export let allowPublic = true
export let allowRemove = false
export let disabled = false
export let align
export let footer = null
export let allowedRoles = null
const dispatch = createEventDispatcher()
const RemoveID = "remove"
$: options = getOptions($roles, allowPublic, allowRemove)
$: options = getOptions($roles, allowPublic, allowRemove, allowedRoles)
const getOptions = (roles, allowPublic) => {
const getOptions = (roles, allowPublic, allowRemove, allowedRoles) => {
if (allowedRoles?.length) {
return roles.filter(role => allowedRoles.includes(role._id))
}
let newRoles = [...roles]
if (allowRemove) {
roles = [
...roles,
newRoles = [
...newRoles,
{
_id: RemoveID,
name: "Remove",
@ -28,9 +36,9 @@
]
}
if (allowPublic) {
return roles
return newRoles
}
return roles.filter(role => role._id !== Constants.Roles.PUBLIC)
return newRoles.filter(role => role._id !== Constants.Roles.PUBLIC)
}
const getColor = role => {
@ -59,6 +67,9 @@
<Select
{autoWidth}
{quiet}
{disabled}
{align}
{footer}
bind:value
on:change={onChange}
{options}

View file

@ -6,8 +6,10 @@
Heading,
Body,
Button,
Icon,
ActionButton,
} from "@budibase/bbui"
import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte"
import { processStringSync } from "@budibase/string-templates"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import analytics, { Events, EventSource } from "analytics"
@ -16,6 +18,9 @@
import { onMount } from "svelte"
import DeployModal from "components/deploy/DeployModal.svelte"
import { apps } from "stores/portal"
import { store } from "builderStore"
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js"
export let application
@ -108,66 +113,97 @@
})
</script>
<div class="deployment-top-nav">
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<Icon
size="M"
hoverable
name="Globe"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
<div class="action-top-nav">
<div class="action-buttons">
<div class="version">
<VersionModal />
</div>
{/if}
<RevertModal />
{#if !isPublished}
<Icon
size="M"
name="GlobeStrike"
disabled
tooltip="Your app has not been published yet"
/>
{/if}
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<ActionButton
quiet
icon="Globe"
size="M"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
</div>
{/if}
{#if !isPublished}
<ActionButton
quiet
icon="GlobeStrike"
size="M"
tooltip="Your app has not been published yet"
disabled
/>
{/if}
<TourWrap
tourStepKey={$store.onboarding
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
>
<span id="builder-app-users-button">
<ActionButton
quiet
icon="UserGroup"
size="M"
on:click={() => {
store.update(state => {
state.builderSidePanel = true
return state
})
}}
>
Users
</ActionButton>
</span>
</TourWrap>
</div>
</div>
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
@ -183,6 +219,11 @@
</div>
<style>
/* .banner-btn {
display: flex;
align-items: center;
gap: var(--spacing-s);
} */
.popover-content {
padding: var(--spacing-xl);
}
@ -191,6 +232,22 @@
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-m);
gap: var(--spacing-l);
}
.action-buttons {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
/* gap: var(--spacing-s); */
}
.version {
margin-right: var(--spacing-s);
}
.action-top-nav {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
</style>

View file

@ -1,10 +1,10 @@
<script>
import {
Icon,
Input,
Modal,
notifications,
ModalContent,
ActionButton,
} from "@budibase/bbui"
import { store } from "builderStore"
import { API } from "api"
@ -28,12 +28,14 @@
}
</script>
<Icon
name="Revert"
hoverable
on:click={revertModal.show}
<ActionButton
quiet
icon="Revert"
size="M"
tooltip="Revert changes"
on:click={revertModal.show}
/>
<Modal bind:this={revertModal}>
<ModalContent
title="Revert Changes"

View file

@ -122,7 +122,9 @@
<Layout noPadding gap="M">
<div class="tour-header">
<Heading size="XS">{tourStep?.title || "-"}</Heading>
<div>{`${tourStepIdx + 1}/${tourSteps?.length}`}</div>
{#if tourSteps?.length > 1}
<div>{`${tourStepIdx + 1}/${tourSteps?.length}`}</div>
{/if}
</div>
<Body size="S">
<span class="tour-body">

View file

@ -6,16 +6,19 @@
export let tourStepKey
let currentTour
let currentTourStep
let ready = false
let handler
onMount(() => {
if (!$store.tourKey) return
currentTour = TOURS[$store.tourKey].find(step => step.id === tourStepKey)
currentTourStep = TOURS[$store.tourKey].find(
step => step.id === tourStepKey
)
if (!currentTourStep) return
const elem = document.querySelector(currentTour.query)
const elem = document.querySelector(currentTourStep.query)
handler = tourHandler(elem, tourStepKey)
ready = true
})

View file

@ -9,11 +9,14 @@ export const TOUR_STEP_KEYS = {
BUILDER_APP_PUBLISH: "builder-app-publish",
BUILDER_DATA_SECTION: "builder-data-section",
BUILDER_DESIGN_SECTION: "builder-design-section",
BUILDER_USER_MANAGEMENT: "builder-user-management",
BUILDER_AUTOMATE_SECTION: "builder-automate-section",
FEATURE_USER_MANAGEMENT: "feature-user-management",
}
export const TOUR_KEYS = {
TOUR_BUILDER_ONBOARDING: "builder-onboarding",
FEATURE_ONBOARDING: "feature-onboarding",
}
const tourEvent = eventKey => {
@ -58,6 +61,15 @@ const getTours = () => {
},
align: "left",
},
{
id: TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT,
title: "Users",
query: ".toprightnav #builder-app-users-button",
body: "Add users to your app and control what level of access they have.",
onLoad: () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT)
},
},
{
id: TOUR_STEP_KEYS.BUILDER_APP_PUBLISH,
title: "Publish",
@ -79,6 +91,37 @@ const getTours = () => {
// Update the cached user
await auth.getSelf()
store.update(state => ({
...state,
tourNodes: undefined,
tourKey: undefined,
tourKeyStep: undefined,
onboarding: false,
}))
}
},
},
],
[TOUR_KEYS.FEATURE_ONBOARDING]: [
{
id: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT,
title: "Users",
query: ".toprightnav #builder-app-users-button",
body: "Add users to your app and control what level of access they have.",
onLoad: () => {
tourEvent(TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT)
},
onComplete: async () => {
// Push the onboarding forward
if (get(auth).user) {
await users.save({
...get(auth).user,
onboardedAt: new Date().toISOString(),
})
// Update the cached user
await auth.getSelf()
store.update(state => ({
...state,
tourNodes: undefined,

View file

@ -0,0 +1,763 @@
<script>
import {
Icon,
Heading,
Layout,
Input,
clickOutside,
notifications,
ActionButton,
} from "@budibase/bbui"
import { store } from "builderStore"
import { groups, licensing, apps, users } from "stores/portal"
import { fetchData } from "@budibase/frontend-core"
import { API } from "api"
import { onMount } from "svelte"
import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte"
import RoleSelect from "components/common/RoleSelect.svelte"
import { Constants, Utils } from "@budibase/frontend-core"
import { emailValidator } from "helpers/validation"
import CopyInput from "components/common/inputs/CopyInput.svelte"
import { roles } from "stores/backend"
let query = null
let loaded = false
let rendered = false
let inviting = false
let searchFocus = false
let appInvites = []
let filteredInvites = []
let filteredUsers = []
let filteredGroups = []
let selectedGroup
let userOnboardResponse = null
$: queryIsEmail = emailValidator(query) === true
$: prodAppId = apps.getProdAppID($store.appId)
$: promptInvite = showInvite(
filteredInvites,
filteredUsers,
filteredGroups,
query
)
const showInvite = (invites, users, groups, query) => {
return !invites?.length && !users?.length && !groups?.length && query
}
const filterInvites = async query => {
appInvites = await getInvites()
if (!query || query == "") {
filteredInvites = appInvites
return
}
filteredInvites = appInvites.filter(invite => invite.email.includes(query))
}
$: filterInvites(query)
const usersFetch = fetchData({
API,
datasource: {
type: "user",
},
})
const searchUsers = async (query, sidePaneOpen, loaded) => {
if (!sidePaneOpen || !loaded) {
return
}
if (!prodAppId) {
console.log("Application id required")
return
}
await usersFetch.update({
query: {
appId: query ? null : prodAppId,
email: query,
paginated: query ? null : false,
},
})
await usersFetch.refresh()
filteredUsers = $usersFetch.rows.map(user => {
const isBuilderOrAdmin = user.admin?.global || user.builder?.global
let role = undefined
if (isBuilderOrAdmin) {
role = Constants.Roles.ADMIN
} else {
const appRole = Object.keys(user.roles).find(x => x === prodAppId)
if (appRole) {
role = user.roles[appRole]
}
}
return {
...user,
role,
isBuilderOrAdmin,
}
})
}
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
$: debouncedUpdateFetch(query, $store.builderSidePanel, loaded)
const updateAppUser = async (user, role) => {
if (!prodAppId) {
notifications.error("Application id must be specified")
return
}
const update = await users.get(user._id)
await users.save({
...update,
roles: {
...update.roles,
[prodAppId]: role,
},
})
await searchUsers(query, $store.builderSidePanel, loaded)
}
const onUpdateUser = async (user, role) => {
if (!user) {
notifications.error("A user must be specified")
return
}
try {
if (user.role === role) {
return
}
await updateAppUser(user, role)
} catch (error) {
console.error(error)
notifications.error("User could not be updated")
}
}
const updateAppGroup = async (target, role) => {
if (!prodAppId) {
notifications.error("Application id must be specified")
return
}
if (!role) {
await groups.actions.removeApp(target._id, prodAppId)
} else {
await groups.actions.addApp(target._id, prodAppId, role)
}
await usersFetch.refresh()
await groups.actions.init()
}
const onUpdateGroup = async (group, role) => {
if (!group) {
notifications.error("A group must be specified")
return
}
try {
await updateAppGroup(group, role)
} catch {
notifications.error("Group update failed")
}
}
const getAppGroups = (allGroups, appId) => {
if (!allGroups) {
return []
}
return allGroups.filter(group => {
if (!group.roles) {
return false
}
return groups.actions.getGroupAppIds(group).includes(appId)
})
}
const searchGroups = (userGroups, query) => {
let filterGroups = query?.length
? userGroups
: getAppGroups(userGroups, prodAppId)
return filterGroups
.filter(group => {
if (!query?.length) {
return true
}
//Group Name only.
const nameMatch = group.name
?.toLowerCase()
.includes(query?.toLowerCase())
return nameMatch
})
.map(enrichGroupRole)
}
const enrichGroupRole = group => {
return {
...group,
role: group.roles?.[
groups.actions.getGroupAppIds(group).find(x => x === prodAppId)
],
}
}
const getEnrichedGroups = groups => {
return groups.map(enrichGroupRole)
}
// Adds the 'role' attribute and sets it to the current app.
$: enrichedGroups = getEnrichedGroups($groups)
$: filteredGroups = searchGroups(enrichedGroups, query)
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
$: allUsers = [...filteredUsers, ...groupUsers]
/*
Create pseudo users from the "users" attribute on app groups.
These users will appear muted in the UI and show the ROLE
inherited from their parent group. The users allow assigning of user
specific roles for the app.
*/
const buildGroupUsers = (userGroups, filteredUsers) => {
if (query) {
return []
}
// Must exclude users who have explicit privileges
const userByEmail = filteredUsers.reduce((acc, user) => {
if (user.role || user.admin?.global || user.builder?.global) {
acc.push(user.email)
}
return acc
}, [])
const indexedUsers = userGroups.reduce((acc, group) => {
group.users.forEach(user => {
if (userByEmail.indexOf(user.email) == -1) {
acc[user._id] = {
_id: user._id,
email: user.email,
role: group.role,
group: group.name,
}
}
})
return acc
}, {})
return Object.values(indexedUsers)
}
const getInvites = async () => {
try {
const invites = await users.getInvites()
return invites
} catch (error) {
notifications.error(error.message)
return []
}
}
async function inviteUser() {
if (!queryIsEmail) {
notifications.error("Email is not valid")
return
}
const newUserEmail = query + ""
inviting = true
const payload = [
{
email: newUserEmail,
builder: false,
admin: false,
apps: { [prodAppId]: Constants.Roles.BASIC },
},
]
let userInviteResponse
try {
userInviteResponse = await users.onboard(payload)
const newUser = userInviteResponse?.successful.find(
user => user.email === newUserEmail
)
if (newUser) {
notifications.success(
userInviteResponse.created
? "User created successfully"
: "User invite successful"
)
} else {
throw new Error("User invite failed")
}
} catch (error) {
console.error(error.message)
notifications.error("Error inviting user")
}
inviting = false
return userInviteResponse
}
const onInviteUser = async () => {
userOnboardResponse = await inviteUser()
const userInviteSuccess = userOnboardResponse?.successful
if (userInviteSuccess && userInviteSuccess[0].email === query) {
query = null
query = userInviteSuccess[0].email
}
}
const onUpdateUserInvite = async (invite, role) => {
await users.updateInvite({
code: invite.code,
apps: {
...invite.apps,
[prodAppId]: role,
},
})
await filterInvites()
}
const onUninviteAppUser = async invite => {
await uninviteAppUser(invite)
await filterInvites()
}
// Purge only the app from the invite or recind the invite if only 1 app remains?
const uninviteAppUser = async invite => {
let updated = { ...invite }
delete updated.info.apps[prodAppId]
return await users.updateInvite({
code: updated.code,
apps: updated.apps,
})
}
const initSidePanel = async sidePaneOpen => {
if (sidePaneOpen === true) {
await groups.actions.init()
}
loaded = true
}
$: initSidePanel($store.builderSidePanel)
onMount(() => {
rendered = true
})
const userTitle = user => {
if (user.admin?.global) {
return "Admin"
} else if (user.builder?.global) {
return "Developer"
} else {
return "App user"
}
}
const getRoleFooter = user => {
if (user.group) {
const role = $roles.find(role => role._id === user.role)
return `This user has been given ${role?.name} access from the ${user.group} group`
}
if (user.isBuilderOrAdmin) {
return "This user's role grants admin access to all apps"
}
return null
}
</script>
<div
id="builder-side-panel-container"
class:open={$store.builderSidePanel}
use:clickOutside={$store.builderSidePanel
? () => {
store.update(state => {
state.builderSidePanel = false
return state
})
}
: () => {}}
>
<div class="builder-side-panel-header">
<Heading size="S">Users</Heading>
<Icon
color="var(--spectrum-global-color-gray-600)"
name="RailRightClose"
hoverable
on:click={() => {
store.update(state => {
state.builderSidePanel = false
return state
})
}}
/>
</div>
<div class="search" class:focused={searchFocus}>
<span class="search-input">
<Input
placeholder={"Add users and groups to your app"}
autocomplete="off"
disabled={inviting}
value={query}
on:input={e => {
query = e.target.value.trim()
}}
on:focus={() => (searchFocus = true)}
on:blur={() => (searchFocus = false)}
/>
</span>
<span
class="search-input-icon"
class:searching={query}
on:click={() => {
if (!query) {
return
}
query = null
userOnboardResponse = null
}}
>
<Icon name={query ? "Close" : "Search"} />
</span>
</div>
<div class="body">
{#if promptInvite && !userOnboardResponse}
<Layout gap="S" paddingX="XL">
<div class="invite-header">
<Heading size="XS">No user found</Heading>
<div class="invite-directions">
Add a valid email to invite a new user
</div>
</div>
<div class="invite-form">
<span>{query || ""}</span>
<ActionButton
icon="UserAdd"
disabled={!queryIsEmail || inviting}
on:click={onInviteUser}
>
Add user
</ActionButton>
</div>
</Layout>
{/if}
{#if !promptInvite}
<Layout gap="L" noPadding>
{#if filteredInvites?.length}
<Layout noPadding gap="XS">
<div class="auth-entity-header">
<div class="auth-entity-title">Pending invites</div>
<div class="auth-entity-access-title">Access</div>
</div>
{#each filteredInvites as invite}
<div class="auth-entity">
<div class="details">
<div class="user-email" title={invite.email}>
{invite.email}
</div>
</div>
<div class="auth-entity-access">
<RoleSelect
placeholder={false}
value={invite.info.apps?.[prodAppId]}
allowRemove={invite.info.apps?.[prodAppId]}
allowPublic={false}
quiet={true}
on:change={e => {
onUpdateUserInvite(invite, e.detail)
}}
on:remove={() => {
onUninviteAppUser(invite)
}}
autoWidth
align="right"
/>
</div>
</div>
{/each}
</Layout>
{/if}
{#if $licensing.groupsEnabled && filteredGroups?.length}
<Layout noPadding gap="XS">
<div class="auth-entity-header">
<div class="auth-entity-title">Groups</div>
<div class="auth-entity-access-title">Access</div>
</div>
{#each filteredGroups as group}
<div
class="auth-entity group"
on:click={() => {
if (selectedGroup != group._id) {
selectedGroup = group._id
} else {
selectedGroup = null
}
}}
on:keydown={() => {}}
>
<div class="details">
<GroupIcon {group} size="S" />
<div>
{group.name}
</div>
<div class="auth-entity-meta">
{`${group.users?.length} user${
group.users?.length != 1 ? "s" : ""
}`}
</div>
</div>
<div class="auth-entity-access">
<RoleSelect
placeholder={false}
value={group.role}
allowRemove={group.role}
allowPublic={false}
quiet={true}
on:change={e => {
onUpdateGroup(group, e.detail)
}}
on:remove={() => {
onUpdateGroup(group)
}}
autoWidth
align="right"
/>
</div>
</div>
{/each}
</Layout>
{/if}
{#if filteredUsers?.length}
<div class="auth-entity-section">
<div class="auth-entity-header ">
<div class="auth-entity-title">Users</div>
<div class="auth-entity-access-title">Access</div>
</div>
{#each allUsers as user}
<div class="auth-entity">
<div class="details">
<div class="user-email" title={user.email}>
{user.email}
</div>
<div class="auth-entity-meta">
{userTitle(user)}
</div>
</div>
<div class="auth-entity-access" class:muted={user.group}>
<RoleSelect
footer={getRoleFooter(user)}
placeholder={false}
value={user.role}
allowRemove={user.role && !user.group}
allowPublic={false}
quiet={true}
on:change={e => {
onUpdateUser(user, e.detail)
}}
on:remove={() => {
onUpdateUser(user)
}}
autoWidth
align="right"
allowedRoles={user.isBuilderOrAdmin
? [Constants.Roles.ADMIN]
: null}
/>
</div>
</div>
{/each}
</div>
{/if}
</Layout>
{/if}
{#if userOnboardResponse?.created}
<Layout gap="S" paddingX="XL">
<div class="invite-header">
<Heading size="XS">User added!</Heading>
<div class="invite-directions">
Email invites are not available without SMTP configuration. Here is
the password that has been generated for this user.
</div>
</div>
<div>
<CopyInput
value={userOnboardResponse.successful[0]?.password}
label="Password"
/>
</div>
</Layout>
{/if}
</div>
</div>
<style>
.search :global(input) {
padding-left: 0px;
}
.search {
display: flex;
align-items: center;
}
.search-input {
flex: 1;
}
.search-input-icon.searching {
cursor: pointer;
}
.auth-entity-section {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
width: 400px;
}
.auth-entity-meta {
color: var(--spectrum-global-color-gray-600);
font-size: 12px;
white-space: nowrap;
}
.auth-entity-access {
margin-right: var(--spacing-m);
}
.auth-entity-access.muted :global(.spectrum-Picker-label),
.auth-entity-access.muted :global(.spectrum-StatusLight) {
opacity: 0.5;
}
.auth-entity-header {
color: var(--spectrum-global-color-gray-600);
}
.auth-entity,
.auth-entity-header {
padding: 0px var(--spacing-xl);
}
.auth-entity,
.auth-entity-header {
display: grid;
grid-template-columns: 1fr 110px;
align-items: center;
gap: var(--spacing-xl);
}
.auth-entity .details {
display: flex;
align-items: center;
gap: var(--spacing-m);
color: var(--spectrum-global-color-gray-900);
}
.auth-entity .user-email {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
color: var(--spectrum-global-color-gray-900);
}
#builder-side-panel-container {
box-sizing: border-box;
max-width: calc(100vw - 40px);
background: var(--background);
border-left: var(--border-light);
z-index: 3;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
transition: transform 130ms ease-out;
position: absolute;
width: 400px;
right: 0;
transform: translateX(100%);
height: 100%;
}
.builder-side-panel-header,
#builder-side-panel-container .search {
padding: 0px var(--spacing-xl);
}
#builder-side-panel-container .auth-entity .details {
box-sizing: border-box;
}
.invite-form {
display: flex;
align-items: center;
justify-content: space-between;
}
#builder-side-panel-container .search {
padding-top: var(--spacing-m);
padding-bottom: var(--spacing-m);
border-top: var(--border-light);
border-bottom: var(--border-light);
border-left: 2px solid transparent;
border-right: 2px solid transparent;
margin-right: 1px;
}
#builder-side-panel-container .search :global(input) {
border: none;
border-radius: 0px;
background: none;
}
#builder-side-panel-container .search :global(input) {
border: none;
border-radius: 0px;
}
#builder-side-panel-container .search.focused {
border-color: var(
--spectrum-textfield-m-border-color-down,
var(--spectrum-alias-border-color-mouse-focus)
);
}
#builder-side-panel-container .search :global(input::placeholder) {
font-style: normal;
}
#builder-side-panel-container.open {
transform: translateX(0);
box-shadow: 0 0 40px 10px rgba(0, 0, 0, 0.1);
}
.builder-side-panel-header {
height: 58px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.invite-header {
display: flex;
gap: var(--spacing-s);
flex-direction: column;
}
.body {
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
padding: var(--spacing-xl) 0;
}
</style>

View file

@ -13,15 +13,14 @@
notifications,
} from "@budibase/bbui"
import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte"
import DeployNavigation from "components/deploy/DeployNavigation.svelte"
import AppActions from "components/deploy/AppActions.svelte"
import { API } from "api"
import { isActive, goto, layout, redirect } from "@roxi/routify"
import { capitalise } from "helpers"
import { onMount, onDestroy } from "svelte"
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import TourPopover from "components/portal/onboarding/TourPopover.svelte"
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js"
export let application
@ -69,22 +68,32 @@
}
const initTour = async () => {
if (
!$auth.user?.onboardedAt &&
isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)
) {
// Determine the correct step
const activeNav = $layout.children.find(c => $isActive(c.path))
const onboardingTour = TOURS[TOUR_KEYS.TOUR_BUILDER_ONBOARDING]
const targetStep = activeNav
? onboardingTour.find(step => step.route === activeNav?.path)
: null
await store.update(state => ({
...state,
onboarding: true,
tourKey: TOUR_KEYS.TOUR_BUILDER_ONBOARDING,
tourStepKey: targetStep?.id,
}))
// Check if onboarding is enabled.
if (isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)) {
if (!$auth.user?.onboardedAt) {
// Determine the correct step
const activeNav = $layout.children.find(c => $isActive(c.path))
const onboardingTour = TOURS[TOUR_KEYS.TOUR_BUILDER_ONBOARDING]
const targetStep = activeNav
? onboardingTour.find(step => step.route === activeNav?.path)
: null
await store.update(state => ({
...state,
onboarding: true,
tourKey: TOUR_KEYS.TOUR_BUILDER_ONBOARDING,
tourStepKey: targetStep?.id,
}))
} else {
// Feature tour date
const release_date = new Date("2023-03-01T00:00:00.000Z")
const onboarded = new Date($auth.user?.onboardedAt)
if (onboarded < release_date) {
await store.update(state => ({
...state,
tourKey: TOUR_KEYS.FEATURE_ONBOARDING,
}))
}
}
}
}
@ -116,6 +125,11 @@
<div class="loading" />
{:then _}
<TourPopover />
{#if $store.builderSidePanel}
<BuilderSidePanel />
{/if}
<div class="root">
<div class="top-nav">
<div class="topleftnav">
@ -181,11 +195,7 @@
</Tabs>
</div>
<div class="toprightnav">
<div class="version">
<VersionModal />
</div>
<RevertModal />
<DeployNavigation {application} />
<AppActions {application} />
</div>
</div>
<slot />
@ -250,10 +260,6 @@
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-xl);
}
.version {
margin-right: var(--spacing-s);
gap: var(--spacing-l);
}
</style>

View file

@ -131,24 +131,25 @@
isEqual(providers.google?.config, originalGoogleDoc?.config)
? (googleSaveButtonDisabled = true)
: (googleSaveButtonDisabled = false)
// delete the callback url which is never saved to the oidc
// config doc, to ensure an accurate comparison
delete providers.oidc?.config.configs[0].callbackURL
isEqual(providers.oidc?.config, originalOidcDoc?.config)
? (oidcSaveButtonDisabled = true)
: (oidcSaveButtonDisabled = false)
}
// Create a flag so that it will only try to save completed forms
$: partialGoogle =
providers.google?.config?.clientID || providers.google?.config?.clientSecret
$: partialOidc =
providers.oidc?.config?.configs[0].configUrl ||
providers.oidc?.config?.configs[0].clientID ||
providers.oidc?.config?.configs[0].clientSecret
$: googleComplete =
$: googleComplete = !!(
providers.google?.config?.clientID && providers.google?.config?.clientSecret
$: oidcComplete =
)
$: oidcComplete = !!(
providers.oidc?.config?.configs[0].configUrl &&
providers.oidc?.config?.configs[0].clientID &&
providers.oidc?.config?.configs[0].clientSecret
)
const onFileSelected = e => {
let fileName = e.target.files[0].name
@ -159,74 +160,88 @@
async function toggleIsSSOEnforced() {
const value = $organisation.isSSOEnforced
await organisation.save({ isSSOEnforced: !value })
try {
await organisation.save({ isSSOEnforced: !value })
} catch (e) {
notifications.error(e.message)
}
}
async function save(docs) {
let calls = []
// Only if the user has provided an image, upload it
async function saveConfig(config) {
// Delete unsupported fields
delete config.createdAt
delete config.updatedAt
return API.saveConfig(config)
}
async function saveOIDCLogo() {
if (image) {
let data = new FormData()
data.append("file", image)
calls.push(
API.uploadOIDCLogo({
name: image.name,
data,
})
await API.uploadOIDCLogo({
name: image.name,
data,
})
}
}
async function saveOIDC() {
if (!oidcComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.OIDC} fields`
)
return
}
docs.forEach(element => {
// Delete unsupported fields
delete element.createdAt
delete element.updatedAt
const { activated } = element.config
const oidc = providers.oidc
if (element.type === ConfigTypes.OIDC) {
// Add a UUID here so each config is distinguishable when it arrives at the login page
for (let config of element.config.configs) {
if (!config.uuid) {
config.uuid = Helpers.uuid()
}
// Callback urls shouldn't be included
delete config.callbackURL
}
if ((partialOidc || activated) && !oidcComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.OIDC} fields`
)
} else if (oidcComplete || !activated) {
calls.push(API.saveConfig(element))
// Turn the save button grey when clicked
oidcSaveButtonDisabled = true
originalOidcDoc = cloneDeep(providers.oidc)
}
// Add a UUID here so each config is distinguishable when it arrives at the login page
for (let config of oidc.config.configs) {
if (!config.uuid) {
config.uuid = Helpers.uuid()
}
if (element.type === ConfigTypes.Google) {
if ((partialGoogle || activated) && !googleComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.Google} fields`
)
} else if (googleComplete || !activated) {
calls.push(API.saveConfig(element))
googleSaveButtonDisabled = true
originalGoogleDoc = cloneDeep(providers.google)
}
}
})
if (calls.length) {
Promise.all(calls)
.then(data => {
data.forEach(res => {
providers[res.type]._rev = res._rev
providers[res.type]._id = res._id
})
notifications.success(`Settings saved`)
})
.catch(() => {
notifications.error("Failed to update auth settings")
})
// Callback urls shouldn't be included
delete config.callbackURL
}
try {
const res = await saveConfig(oidc)
providers[res.type]._rev = res._rev
providers[res.type]._id = res._id
await saveOIDCLogo()
notifications.success(`Settings saved`)
} catch (e) {
notifications.error(e.message)
return
}
// Turn the save button grey when clicked
oidcSaveButtonDisabled = true
originalOidcDoc = cloneDeep(providers.oidc)
}
async function saveGoogle() {
if (!googleComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.Google} fields`
)
return
}
const google = providers.google
try {
const res = await saveConfig(google)
providers[res.type]._rev = res._rev
providers[res.type]._id = res._id
notifications.success(`Settings saved`)
} catch (e) {
notifications.error(e.message)
return
}
googleSaveButtonDisabled = true
originalGoogleDoc = cloneDeep(providers.google)
}
let defaultScopes = ["profile", "email", "offline_access"]
@ -266,7 +281,7 @@
if (!googleDoc?._id) {
providers.google = {
type: ConfigTypes.Google,
config: { activated: true },
config: { activated: false },
}
originalGoogleDoc = cloneDeep(googleDoc)
} else {
@ -290,14 +305,17 @@
}
if (oidcLogos?.config) {
const logoKeys = Object.keys(oidcLogos.config)
logoKeys.map(logoKey => {
const logoUrl = oidcLogos.config[logoKey]
iconDropdownOptions.unshift({
label: logoKey,
value: logoKey,
icon: logoUrl,
logoKeys
// don't include the etag entry in the logo config
.filter(key => !key.toLowerCase().includes("etag"))
.map(logoKey => {
const logoUrl = oidcLogos.config[logoKey]
iconDropdownOptions.unshift({
label: logoKey,
value: logoKey,
icon: logoUrl,
})
})
})
}
// Fetch OIDC config
@ -310,7 +328,7 @@
if (!oidcDoc?._id) {
providers.oidc = {
type: ConfigTypes.OIDC,
config: { configs: [{ activated: true, scopes: defaultScopes }] },
config: { configs: [{ activated: false, scopes: defaultScopes }] },
}
} else {
originalOidcDoc = cloneDeep(oidcDoc)
@ -413,7 +431,7 @@
<Button
disabled={googleSaveButtonDisabled}
cta
on:click={() => save([providers.google])}
on:click={() => saveGoogle()}
>
Save
</Button>
@ -469,6 +487,7 @@
<Select
label=""
bind:value={providers.oidc.config.configs[0].logo}
useOptionIconImage
options={iconDropdownOptions}
on:change={e => e.detail === "Upload" && fileinput.click()}
/>
@ -575,11 +594,7 @@
</div>
</Layout>
<div>
<Button
disabled={oidcSaveButtonDisabled}
cta
on:click={() => save([providers.oidc])}
>
<Button disabled={oidcSaveButtonDisabled} cta on:click={() => saveOIDC()}>
Save
</Button>
</div>

View file

@ -1,6 +1,7 @@
import { writable, get } from "svelte/store"
import { API } from "api"
import { auth } from "stores/portal"
import _ from "lodash"
const DEFAULT_CONFIG = {
platformUrl: "",
@ -26,14 +27,14 @@ export function createOrganisationStore() {
async function save(config) {
// Delete non-persisted fields
const storeConfig = get(store)
const storeConfig = _.cloneDeep(get(store))
delete storeConfig.oidc
delete storeConfig.google
delete storeConfig.oidcCallbackUrl
delete storeConfig.googleCallbackUrl
await API.saveConfig({
type: "settings",
config: { ...get(store), ...config },
config: { ...storeConfig, ...config },
})
await init()
}

View file

@ -26,9 +26,15 @@ export function createUsersStore() {
return await API.getUsers()
}
// One or more users.
async function onboard(payload) {
return await API.onboardUsers(payload)
}
async function invite(payload) {
return API.inviteUsers(payload)
}
async function acceptInvite(inviteCode, password, firstName, lastName) {
return API.acceptInvite({
inviteCode,
@ -42,6 +48,14 @@ export function createUsersStore() {
return API.getUserInvite(inviteCode)
}
async function getInvites() {
return API.getUserInvites()
}
async function updateInvite(invite) {
return API.updateUserInvite(invite)
}
async function create(data) {
let mappedUsers = data.users.map(user => {
const body = {
@ -106,8 +120,11 @@ export function createUsersStore() {
getUserRole,
fetch,
invite,
onboard,
acceptInvite,
fetchInvite,
getInvites,
updateInvite,
create,
save,
bulkDelete,

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/cli",
"version": "2.3.18-alpha.14",
"version": "2.3.18-alpha.17",
"description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js",
"bin": {
@ -26,9 +26,9 @@
"outputPath": "build"
},
"dependencies": {
"@budibase/backend-core": "2.3.18-alpha.14",
"@budibase/string-templates": "2.3.18-alpha.14",
"@budibase/types": "2.3.18-alpha.14",
"@budibase/backend-core": "2.3.18-alpha.17",
"@budibase/string-templates": "2.3.18-alpha.17",
"@budibase/types": "2.3.18-alpha.17",
"axios": "0.21.2",
"chalk": "4.1.0",
"cli-progress": "3.11.2",

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/client",
"version": "2.3.18-alpha.14",
"version": "2.3.18-alpha.17",
"license": "MPL-2.0",
"module": "dist/budibase-client.js",
"main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw"
},
"dependencies": {
"@budibase/bbui": "2.3.18-alpha.14",
"@budibase/frontend-core": "2.3.18-alpha.14",
"@budibase/string-templates": "2.3.18-alpha.14",
"@budibase/bbui": "2.3.18-alpha.17",
"@budibase/frontend-core": "2.3.18-alpha.17",
"@budibase/string-templates": "2.3.18-alpha.17",
"@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3",

View file

@ -1,12 +1,12 @@
{
"name": "@budibase/frontend-core",
"version": "2.3.18-alpha.14",
"version": "2.3.18-alpha.17",
"description": "Budibase frontend core libraries used in builder and client",
"author": "Budibase",
"license": "MPL-2.0",
"svelte": "src/index.js",
"dependencies": {
"@budibase/bbui": "2.3.18-alpha.14",
"@budibase/bbui": "2.3.18-alpha.17",
"lodash": "^4.17.21",
"svelte": "^3.46.2"
}

View file

@ -12,8 +12,10 @@ export const buildUserEndpoints = API => ({
* Gets a list of users in the current tenant.
* @param {string} page The page to retrieve
* @param {string} search The starts with string to search username/email by.
* @param {string} appId Facilitate app/role based user searching
* @param {boolean} paginated Allow the disabling of pagination
*/
searchUsers: async ({ page, email, appId } = {}) => {
searchUsers: async ({ paginated, page, email, appId } = {}) => {
const opts = {}
if (page) {
opts.page = page
@ -24,6 +26,9 @@ export const buildUserEndpoints = API => ({
if (appId) {
opts.appId = appId
}
if (typeof paginated === "boolean") {
opts.paginated = paginated
}
return await API.post({
url: `/api/global/users/search`,
body: opts,
@ -133,7 +138,7 @@ export const buildUserEndpoints = API => ({
* @param builder whether the user should be a global builder
* @param admin whether the user should be a global admin
*/
inviteUser: async ({ email, builder, admin }) => {
inviteUser: async ({ email, builder, admin, apps }) => {
return await API.post({
url: "/api/global/users/invite",
body: {
@ -141,11 +146,43 @@ export const buildUserEndpoints = API => ({
userInfo: {
admin: admin ? { global: true } : undefined,
builder: builder ? { global: true } : undefined,
apps: apps ? apps : undefined,
},
},
})
},
onboardUsers: async payload => {
return await API.post({
url: "/api/global/users/onboard",
body: payload.map(invite => {
const { email, admin, builder, apps } = invite
return {
email,
userInfo: {
admin: admin ? { global: true } : undefined,
builder: builder ? { global: true } : undefined,
apps: apps ? apps : undefined,
},
}
}),
})
},
/**
* Accepts a user invite as a body and will update the associated app roles.
* for an existing invite
* @param invite the invite code sent in the email
*/
updateUserInvite: async invite => {
await API.post({
url: `/api/global/users/invite/update/${invite.code}`,
body: {
apps: invite.apps,
},
})
},
/**
* Retrieves the invitation associated with a provided code.
* @param code The unique code for the target invite
@ -156,6 +193,16 @@ export const buildUserEndpoints = API => ({
})
},
/**
* Retrieves the invitation associated with a provided code.
* @param code The unique code for the target invite
*/
getUserInvites: async () => {
return await API.get({
url: `/api/global/users/invites`,
})
},
/**
* Invites multiple users to the current tenant.
* @param users An array of users to invite
@ -169,6 +216,7 @@ export const buildUserEndpoints = API => ({
admin: user.admin ? { global: true } : undefined,
builder: user.admin || user.builder ? { global: true } : undefined,
userGroups: user.groups,
roles: user.apps ? user.apps : undefined,
},
})),
})

View file

@ -35,6 +35,7 @@ export default class UserFetch extends DataFetch {
page: cursor,
email: query.email,
appId: query.appId,
paginated: query.paginated,
})
return {
rows: res?.data || [],

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/sdk",
"version": "2.3.18-alpha.14",
"version": "2.3.18-alpha.17",
"description": "Budibase Public API SDK",
"author": "Budibase",
"license": "MPL-2.0",

View file

@ -172,6 +172,9 @@ module FetchMock {
),
ok: true,
})
} else if (url === "https://www.googleapis.com/oauth2/v4/token") {
// any valid response
return json({})
} else if (url.includes("failonce.com")) {
failCount++
if (failCount === 1) {

View file

@ -1,7 +1,7 @@
{
"name": "@budibase/server",
"email": "hi@budibase.com",
"version": "2.3.18-alpha.14",
"version": "2.3.18-alpha.17",
"description": "Budibase Web Server",
"main": "src/index.ts",
"repository": {
@ -43,11 +43,11 @@
"license": "GPL-3.0",
"dependencies": {
"@apidevtools/swagger-parser": "10.0.3",
"@budibase/backend-core": "2.3.18-alpha.14",
"@budibase/client": "2.3.18-alpha.14",
"@budibase/pro": "2.3.18-alpha.14",
"@budibase/string-templates": "2.3.18-alpha.14",
"@budibase/types": "2.3.18-alpha.14",
"@budibase/backend-core": "2.3.18-alpha.17",
"@budibase/client": "2.3.18-alpha.17",
"@budibase/pro": "2.3.18-alpha.17",
"@budibase/string-templates": "2.3.18-alpha.17",
"@budibase/types": "2.3.18-alpha.17",
"@bull-board/api": "3.7.0",
"@bull-board/koa": "3.9.4",
"@elastic/elasticsearch": "7.10.0",

View file

@ -11,8 +11,8 @@ import { OAuth2Client } from "google-auth-library"
import { buildExternalTableId } from "./utils"
import { DataSourceOperation, FieldTypes } from "../constants"
import { GoogleSpreadsheet } from "google-spreadsheet"
import fetch from "node-fetch"
import { configs, HTTPError } from "@budibase/backend-core"
const fetch = require("node-fetch")
interface GoogleSheetsConfig {
spreadsheetId: string
@ -111,7 +111,7 @@ const SCHEMA: Integration = {
class GoogleSheetsIntegration implements DatasourcePlus {
private readonly config: GoogleSheetsConfig
private client: any
private client: GoogleSpreadsheet
public tables: Record<string, Table> = {}
public schemaErrors: Record<string, string> = {}
@ -172,7 +172,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
async connect() {
try {
// Initialise oAuth client
let googleConfig = await configs.getGoogleConfig()
let googleConfig = await configs.getGoogleDatasourceConfig()
if (!googleConfig) {
throw new HTTPError("Google config not found", 400)
}
@ -203,7 +203,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
async buildSchema(datasourceId: string) {
await this.connect()
const sheets = await this.client.sheetsByIndex
const sheets = this.client.sheetsByIndex
const tables: Record<string, Table> = {}
for (let sheet of sheets) {
// must fetch rows to determine schema
@ -286,7 +286,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
async updateTable(table?: any) {
try {
await this.connect()
const sheet = await this.client.sheetsByTitle[table.name]
const sheet = this.client.sheetsByTitle[table.name]
await sheet.loadHeaderRow()
if (table._rename) {
@ -300,10 +300,17 @@ class GoogleSheetsIntegration implements DatasourcePlus {
}
await sheet.setHeaderRow(headers)
} else {
let newField = Object.keys(table.schema).find(
const updatedHeaderValues = [...sheet.headerValues]
const newField = Object.keys(table.schema).find(
key => !sheet.headerValues.includes(key)
)
await sheet.setHeaderRow([...sheet.headerValues, newField])
if (newField) {
updatedHeaderValues.push(newField)
}
await sheet.setHeaderRow(updatedHeaderValues)
}
} catch (err) {
console.error("Error updating table in google sheets", err)
@ -314,7 +321,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
async deleteTable(sheet: any) {
try {
await this.connect()
const sheetToDelete = await this.client.sheetsByTitle[sheet]
const sheetToDelete = this.client.sheetsByTitle[sheet]
return await sheetToDelete.delete()
} catch (err) {
console.error("Error deleting table in google sheets", err)
@ -325,7 +332,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
async create(query: { sheet: string; row: any }) {
try {
await this.connect()
const sheet = await this.client.sheetsByTitle[query.sheet]
const sheet = this.client.sheetsByTitle[query.sheet]
const rowToInsert =
typeof query.row === "string" ? JSON.parse(query.row) : query.row
const row = await sheet.addRow(rowToInsert)
@ -341,7 +348,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
async read(query: { sheet: string }) {
try {
await this.connect()
const sheet = await this.client.sheetsByTitle[query.sheet]
const sheet = this.client.sheetsByTitle[query.sheet]
const rows = await sheet.getRows()
const headerValues = sheet.headerValues
const response = []
@ -360,7 +367,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
async update(query: { sheet: string; rowIndex: number; row: any }) {
try {
await this.connect()
const sheet = await this.client.sheetsByTitle[query.sheet]
const sheet = this.client.sheetsByTitle[query.sheet]
const rows = await sheet.getRows()
const row = rows[query.rowIndex]
if (row) {
@ -384,7 +391,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
async delete(query: { sheet: string; rowIndex: number }) {
await this.connect()
const sheet = await this.client.sheetsByTitle[query.sheet]
const sheet = this.client.sheetsByTitle[query.sheet]
const rows = await sheet.getRows()
const row = rows[query.rowIndex]
if (row) {

View file

@ -0,0 +1,122 @@
import type { GoogleSpreadsheetWorksheet } from "google-spreadsheet"
jest.mock("google-auth-library")
const { OAuth2Client } = require("google-auth-library")
const setCredentialsMock = jest.fn()
const getAccessTokenMock = jest.fn()
OAuth2Client.mockImplementation(() => {
return {
setCredentials: setCredentialsMock,
getAccessToken: getAccessTokenMock,
}
})
jest.mock("google-spreadsheet")
const { GoogleSpreadsheet } = require("google-spreadsheet")
const sheetsByTitle: { [title: string]: GoogleSpreadsheetWorksheet } = {}
GoogleSpreadsheet.mockImplementation(() => {
return {
useOAuth2Client: jest.fn(),
loadInfo: jest.fn(),
sheetsByTitle,
}
})
import { structures } from "@budibase/backend-core/tests"
import TestConfiguration from "../../tests/utilities/TestConfiguration"
import GoogleSheetsIntegration from "../googlesheets"
import { FieldType, Table, TableSchema } from "../../../../types/src/documents"
describe("Google Sheets Integration", () => {
let integration: any,
config = new TestConfiguration()
beforeEach(async () => {
integration = new GoogleSheetsIntegration.integration({
spreadsheetId: "randomId",
auth: {
appId: "appId",
accessToken: "accessToken",
refreshToken: "refreshToken",
},
})
await config.init()
})
function createBasicTable(name: string, columns: string[]): Table {
return {
name,
schema: {
...columns.reduce((p, c) => {
p[c] = {
name: c,
type: FieldType.STRING,
constraints: {
type: "string",
},
}
return p
}, {} as TableSchema),
},
}
}
function createSheet({
headerValues,
}: {
headerValues: string[]
}): GoogleSpreadsheetWorksheet {
return {
// to ignore the unmapped fields
...({} as any),
loadHeaderRow: jest.fn(),
headerValues,
setHeaderRow: jest.fn(),
}
}
describe("update table", () => {
test("adding a new field will be adding a new header row", async () => {
await config.doInContext(structures.uuid(), async () => {
const tableColumns = ["name", "description", "new field"]
const table = createBasicTable(structures.uuid(), tableColumns)
const sheet = createSheet({ headerValues: ["name", "description"] })
sheetsByTitle[table.name] = sheet
await integration.updateTable(table)
expect(sheet.loadHeaderRow).toBeCalledTimes(1)
expect(sheet.setHeaderRow).toBeCalledTimes(1)
expect(sheet.setHeaderRow).toBeCalledWith(tableColumns)
})
})
test("removing an existing field will not remove the data from the spreadsheet", async () => {
await config.doInContext(structures.uuid(), async () => {
const tableColumns = ["name"]
const table = createBasicTable(structures.uuid(), tableColumns)
const sheet = createSheet({
headerValues: ["name", "description", "location"],
})
sheetsByTitle[table.name] = sheet
await integration.updateTable(table)
expect(sheet.loadHeaderRow).toBeCalledTimes(1)
expect(sheet.setHeaderRow).toBeCalledTimes(1)
expect(sheet.setHeaderRow).toBeCalledWith([
"name",
"description",
"location",
])
// No undefineds are sent
expect((sheet.setHeaderRow as any).mock.calls[0][0]).toHaveLength(3)
})
})
})
})

View file

@ -1278,14 +1278,14 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@budibase/backend-core@2.3.18-alpha.14":
version "2.3.18-alpha.14"
resolved "https://registry.yarnpkg.com/@budibase/backend-core/-/backend-core-2.3.18-alpha.14.tgz#84c10d5840a61437c77c62cb27aa52a48ebea34c"
integrity sha512-8MlNAJNFhct4CwN49wu7EBZJQyToSnUlhZeBlvv94AQxKF6iTzTq40CoNIMCv69nzbeTbDw/ImG7dPW8kBHjpg==
"@budibase/backend-core@2.3.18-alpha.17":
version "2.3.18-alpha.17"
resolved "https://registry.yarnpkg.com/@budibase/backend-core/-/backend-core-2.3.18-alpha.17.tgz#4b887d161a0ad6f21bf8f582417cfec90c2c417f"
integrity sha512-tXza/NP4pA08FjIyToPqJSQcYFL03RFJRdeT6aA0u8Ibd6ASSXZ/iVw7t0VEk57S4G7fftXA5e6Q3XX8PRR2+A==
dependencies:
"@budibase/nano" "10.1.1"
"@budibase/pouchdb-replication-stream" "1.2.10"
"@budibase/types" "2.3.18-alpha.14"
"@budibase/types" "2.3.18-alpha.17"
"@shopify/jest-koa-mocks" "5.0.1"
"@techpass/passport-openidconnect" "0.3.2"
aws-cloudfront-sign "2.2.0"
@ -1367,6 +1367,31 @@
svelte-flatpickr "^3.2.3"
svelte-portal "^1.0.0"
"@budibase/handlebars-helpers@^0.11.8":
version "0.11.8"
resolved "https://registry.yarnpkg.com/@budibase/handlebars-helpers/-/handlebars-helpers-0.11.8.tgz#6953d29673a8c5c407e096c0a84890465c7ce841"
integrity sha512-ggWJUt0GqsHFAEup5tlWlcrmYML57nKhpNGGLzVsqXVYN8eVmf3xluYmmMe7fDYhQH0leSprrdEXmsdFQF3HAQ==
dependencies:
array-sort "^1.0.0"
define-property "^2.0.2"
extend-shallow "^3.0.2"
for-in "^1.0.2"
get-object "^0.2.0"
get-value "^3.0.1"
handlebars "^4.7.7"
handlebars-utils "^1.0.6"
has-value "^2.0.2"
helper-md "^0.2.2"
html-tag "^2.0.0"
is-even "^1.0.0"
is-glob "^4.0.1"
kind-of "^6.0.3"
micromatch "^3.1.5"
relative "^3.0.2"
striptags "^3.1.1"
to-gfm-code-block "^0.1.1"
year "^0.2.1"
"@budibase/nano@10.1.1":
version "10.1.1"
resolved "https://registry.yarnpkg.com/@budibase/nano/-/nano-10.1.1.tgz#36ccda4d9bb64b5ee14dd2b27a295b40739b1038"
@ -1392,18 +1417,20 @@
pouchdb-promise "^6.0.4"
through2 "^2.0.0"
"@budibase/pro@2.3.18-alpha.14":
version "2.3.18-alpha.14"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.3.18-alpha.14.tgz#037045c7c315c23d2981abdcd35fb18bc1a4727d"
integrity sha512-15OFi/Kycp8lYTP424fMoF1l+l3M/0pcDOHpkn0sHix4KhQz2mnqE2iu3k1FR/w9tpPEGHwdd4++BHiDw6h1ZQ==
"@budibase/pro@2.3.18-alpha.17":
version "2.3.18-alpha.17"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-2.3.18-alpha.17.tgz#4eceff71514ffa5254082bf5b15f9f82f5b1bdf4"
integrity sha512-Xsk3kw1MnwGtWI3aNheOdmphSjRjwh0dx/VGTP6MHiFObCKYUxrxm6VmzS9ysw5HxCfyRG8Y2w2EuXoJ08jxtQ==
dependencies:
"@budibase/backend-core" "2.3.18-alpha.14"
"@budibase/types" "2.3.18-alpha.14"
"@budibase/backend-core" "2.3.18-alpha.17"
"@budibase/string-templates" "2.3.18-alpha.14"
"@budibase/types" "2.3.18-alpha.17"
"@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"
"@budibase/standard-components@^0.9.139":
@ -1424,10 +1451,22 @@
svelte-apexcharts "^1.0.2"
svelte-flatpickr "^3.1.0"
"@budibase/types@2.3.18-alpha.14":
"@budibase/string-templates@2.3.18-alpha.14":
version "2.3.18-alpha.14"
resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.3.18-alpha.14.tgz#3fa32f0b262169c4c8679f38f5e0e321f43f54dc"
integrity sha512-mkp0GAqB7zAeLSNV8//dBjSH9dP9z8Q/Sxv29zlExKAxBHlUcYIB442QZJ8Z2V8Tzb+DlRIK7SLTG622cfyUgg==
resolved "https://registry.yarnpkg.com/@budibase/string-templates/-/string-templates-2.3.18-alpha.14.tgz#c3b8d45ced321088c76bcda4efd7e9c7635a2788"
integrity sha512-xamfugDHgvzupe3EkvTY7ymXn9cRxb61nKaap52NsQQl8Zby2W2qJNVBNnuSnhnkQQeF5EatIFgGni+yBDchtQ==
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.4"
"@budibase/types@2.3.18-alpha.17":
version "2.3.18-alpha.17"
resolved "https://registry.yarnpkg.com/@budibase/types/-/types-2.3.18-alpha.17.tgz#603a1374b601720ed39e047367fbb71fb5a1c51f"
integrity sha512-e+hJBt7LxbOjEcjklfNlzn59yODAgdjd3nhH8d/7Mv9q1tcp92ssjN6gLcFCuNSvFdH861J/ln0ApbkcNAOeTg==
"@bull-board/api@3.7.0":
version "3.7.0"
@ -4230,7 +4269,7 @@ arg@^4.1.0:
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
argparse@^1.0.7:
argparse@^1.0.10, argparse@^1.0.7:
version "1.0.10"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==
@ -4277,6 +4316,15 @@ array-equal@^1.0.0:
resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93"
integrity sha512-H3LU5RLiSsGXPhN+Nipar0iR0IofH+8r89G2y1tBKxQ/agagKyAjhkAFDRBfodP2caPrNKHpAWNIM/c9yeL7uA==
array-sort@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/array-sort/-/array-sort-1.0.0.tgz#e4c05356453f56f53512a7d1d6123f2c54c0a88a"
integrity sha512-ihLeJkonmdiAsD7vpgN3CRcx2J2S0TiYW+IS/5zHBI7mKUq3ySvBdzzBfD236ubDBQFiiyG3SWCPc+msQ9KoYg==
dependencies:
default-compare "^1.0.0"
get-value "^2.0.6"
kind-of "^5.0.2"
array-union@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
@ -4420,6 +4468,13 @@ atomic-sleep@^1.0.0:
resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b"
integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==
autolinker@~0.28.0:
version "0.28.1"
resolved "https://registry.yarnpkg.com/autolinker/-/autolinker-0.28.1.tgz#0652b491881879f0775dace0cdca3233942a4e47"
integrity sha512-zQAFO1Dlsn69eXaO6+7YZc+v84aquQKbwpzCE3L0stj56ERn9hutFxPopViLjo9G+rWwjozRhgS5KJ25Xy19cQ==
dependencies:
gulp-header "^1.7.1"
available-typed-arrays@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7"
@ -5500,6 +5555,13 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
concat-with-sourcemaps@*:
version "1.1.0"
resolved "https://registry.yarnpkg.com/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz#d4ea93f05ae25790951b99e7b3b09e3908a4082e"
integrity sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==
dependencies:
source-map "^0.6.1"
condense-newlines@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/condense-newlines/-/condense-newlines-0.2.1.tgz#3de985553139475d32502c83b02f60684d24c55f"
@ -5941,6 +6003,13 @@ deepmerge@^4.2.2:
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
default-compare@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/default-compare/-/default-compare-1.0.0.tgz#cb61131844ad84d84788fb68fd01681ca7781a2f"
integrity sha512-QWfXlM0EkAbqOCbD/6HjdwT19j7WCkMyiRhWilc4H9/5h/RzTF9gv5LYh1+CmDV5d1rki6KAWLtQale0xt20eQ==
dependencies:
kind-of "^5.0.2"
default-shell@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/default-shell/-/default-shell-1.0.1.tgz#752304bddc6174f49eb29cb988feea0b8813c8bc"
@ -6417,6 +6486,11 @@ enhanced-resolve@^5.9.3:
graceful-fs "^4.2.4"
tapable "^2.2.0"
ent@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d"
integrity sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==
entities@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
@ -7617,6 +7691,14 @@ get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1:
has "^1.0.3"
has-symbols "^1.0.3"
get-object@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/get-object/-/get-object-0.2.0.tgz#d92ff7d5190c64530cda0543dac63a3d47fe8c0c"
integrity sha512-7P6y6k6EzEFmO/XyUyFlXm1YLJy9xeA1x/grNV8276abX5GuwUtYgKFkRFkLixw4hf4Pz9q2vgv/8Ar42R0HuQ==
dependencies:
is-number "^2.0.2"
isobject "^0.2.0"
get-package-type@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a"
@ -7679,6 +7761,13 @@ get-value@^2.0.3, get-value@^2.0.6:
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
integrity sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==
get-value@^3.0.0, get-value@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/get-value/-/get-value-3.0.1.tgz#5efd2a157f1d6a516d7524e124ac52d0a39ef5a8"
integrity sha512-mKZj9JLQrwMBtj5wxi6MH8Z5eSKaERpAwjg43dPtlGI1ZVEgH/qC7T8/6R2OBSUA+zzHBZgICsVJaEIV2tKTDA==
dependencies:
isobject "^3.0.1"
getopts@2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/getopts/-/getopts-2.3.0.tgz#71e5593284807e03e2427449d4f6712a268666f4"
@ -7966,7 +8055,24 @@ gtoken@^5.0.4:
google-p12-pem "^3.1.3"
jws "^4.0.0"
handlebars@^4.7.7:
gulp-header@^1.7.1:
version "1.8.12"
resolved "https://registry.yarnpkg.com/gulp-header/-/gulp-header-1.8.12.tgz#ad306be0066599127281c4f8786660e705080a84"
integrity sha512-lh9HLdb53sC7XIZOYzTXM4lFuXElv3EVkSDhsd7DoJBj7hm+Ni7D3qYbb+Rr8DuM8nRanBvkVO9d7askreXGnQ==
dependencies:
concat-with-sourcemaps "*"
lodash.template "^4.4.0"
through2 "^2.0.0"
handlebars-utils@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/handlebars-utils/-/handlebars-utils-1.0.6.tgz#cb9db43362479054782d86ffe10f47abc76357f9"
integrity sha512-d5mmoQXdeEqSKMtQQZ9WkiUcO1E3tPbWxluCK9hVgIDPzQa9WsKo3Lbe/sGflTe7TomHEeZaOgwIkyIr1kfzkw==
dependencies:
kind-of "^6.0.0"
typeof-article "^0.1.1"
handlebars@^4.7.6, handlebars@^4.7.7:
version "4.7.7"
resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1"
integrity sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==
@ -8060,6 +8166,14 @@ has-value@^1.0.0:
has-values "^1.0.0"
isobject "^3.0.0"
has-value@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/has-value/-/has-value-2.0.2.tgz#d0f12e8780ba8e90e66ad1a21c707fdb67c25658"
integrity sha512-ybKOlcRsK2MqrM3Hmz/lQxXHZ6ejzSPzpNabKB45jb5qDgJvKPa3SdapTsTLwEb9WltgWpOmNax7i+DzNOk4TA==
dependencies:
get-value "^3.0.0"
has-values "^2.0.1"
has-values@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771"
@ -8073,6 +8187,13 @@ has-values@^1.0.0:
is-number "^3.0.0"
kind-of "^4.0.0"
has-values@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/has-values/-/has-values-2.0.1.tgz#3876200ff86d8a8546a9264a952c17d5fc17579d"
integrity sha512-+QdH3jOmq9P8GfdjFg0eJudqx1FqU62NQJ4P16rOEHeRdl7ckgwn6uqQjzYE0ZoHVV/e5E2esuJ5Gl5+HUW19w==
dependencies:
kind-of "^6.0.2"
has-yarn@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77"
@ -8085,6 +8206,16 @@ has@^1.0.3:
dependencies:
function-bind "^1.1.1"
helper-md@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/helper-md/-/helper-md-0.2.2.tgz#c1f59d7e55bbae23362fd8a0e971607aec69d41f"
integrity sha512-49TaQzK+Ic7ZVTq4i1UZxRUJEmAilTk8hz7q4I0WNUaTclLR8ArJV5B3A1fe1xF2HtsDTr2gYKLaVTof/Lt84Q==
dependencies:
ent "^2.2.0"
extend-shallow "^2.0.1"
fs-exists-sync "^0.1.0"
remarkable "^1.6.2"
hexoid@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18"
@ -8119,6 +8250,14 @@ html-escaper@^2.0.0:
resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453"
integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==
html-tag@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/html-tag/-/html-tag-2.0.0.tgz#36c3bc8d816fd30b570d5764a497a641640c2fed"
integrity sha512-XxzooSo6oBoxBEUazgjdXj7VwTn/iSTSZzTYKzYY6I916tkaYzypHxy+pbVU1h+0UQ9JlVf5XkNQyxOAiiQO1g==
dependencies:
is-self-closing "^1.0.1"
kind-of "^6.0.0"
http-assert@^1.3.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/http-assert/-/http-assert-1.5.0.tgz#c389ccd87ac16ed2dfa6246fd73b926aa00e6b8f"
@ -8595,6 +8734,13 @@ is-docker@^2.0.0, is-docker@^2.1.1:
resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
is-even@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/is-even/-/is-even-1.0.0.tgz#76b5055fbad8d294a86b6a949015e1c97b717c06"
integrity sha512-LEhnkAdJqic4Dbqn58A0y52IXoHWlsueqQkKfMfdEnIYG8A1sm/GHidKkS6yvXlMoRrkM34csHnXQtOqcb+Jzg==
dependencies:
is-odd "^0.1.2"
is-extendable@^0.1.0, is-extendable@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
@ -8701,6 +8847,13 @@ is-number-object@^1.0.4:
dependencies:
has-tostringtag "^1.0.0"
is-number@^2.0.2:
version "2.1.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
integrity sha512-QUzH43Gfb9+5yckcrSA0VBDwEtDUchrk4F6tfJZQuNzDJbEDB9cZNzSfXGQ1jqmdDY/kl41lUOWM9syA8z8jlg==
dependencies:
kind-of "^3.0.2"
is-number@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195"
@ -8723,6 +8876,13 @@ is-object@^1.0.1:
resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.2.tgz#a56552e1c665c9e950b4a025461da87e72f86fcf"
integrity sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==
is-odd@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/is-odd/-/is-odd-0.1.2.tgz#bc573b5ce371ef2aad6e6f49799b72bef13978a7"
integrity sha512-Ri7C2K7o5IrUU9UEI8losXJCCD/UtsaIrkR5sxIcFg4xQ9cRJXlWA5DQvTE0yDc0krvSNLsRGXN11UPS6KyfBw==
dependencies:
is-number "^3.0.0"
is-path-inside@^3.0.2:
version "3.0.3"
resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
@ -8763,6 +8923,13 @@ is-retry-allowed@^2.2.0:
resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz#88f34cbd236e043e71b6932d09b0c65fb7b4d71d"
integrity sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==
is-self-closing@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-self-closing/-/is-self-closing-1.0.1.tgz#5f406b527c7b12610176320338af0fa3896416e4"
integrity sha512-E+60FomW7Blv5GXTlYee2KDrnG6srxF7Xt1SjrhWUGUEsTFIqY/nq2y3DaftCsgUMdh89V07IVfhY9KIJhLezg==
dependencies:
self-closing-tags "^1.0.1"
is-shared-array-buffer@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79"
@ -8878,6 +9045,11 @@ isexe@^2.0.0:
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
isobject@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/isobject/-/isobject-0.2.0.tgz#a3432192f39b910b5f02cc989487836ec70aa85e"
integrity sha512-VaWq6XYAsbvM0wf4dyBO7WH9D7GosB7ZZlqrawI9BBiTMINBeCyqSKBa35m870MY3O4aM31pYyZi9DfGrYMJrQ==
isobject@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89"
@ -10072,7 +10244,7 @@ keyv@^3.0.0:
dependencies:
json-buffer "3.0.0"
kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.1.0, kind-of@^3.2.0:
version "3.2.2"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
integrity sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==
@ -10086,12 +10258,12 @@ kind-of@^4.0.0:
dependencies:
is-buffer "^1.1.5"
kind-of@^5.0.0:
kind-of@^5.0.0, kind-of@^5.0.2:
version "5.1.0"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==
kind-of@^6.0.0, kind-of@^6.0.2:
kind-of@^6.0.0, kind-of@^6.0.2, kind-of@^6.0.3:
version "6.0.3"
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
@ -10518,6 +10690,11 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"
lodash._reinterpolate@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
integrity sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==
lodash.camelcase@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
@ -10628,6 +10805,21 @@ lodash.sortby@^4.7.0:
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==
lodash.template@^4.4.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"
integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==
dependencies:
lodash._reinterpolate "^3.0.0"
lodash.templatesettings "^4.0.0"
lodash.templatesettings@^4.0.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33"
integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==
dependencies:
lodash._reinterpolate "^3.0.0"
lodash.uniq@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
@ -10643,7 +10835,7 @@ lodash.xor@^4.5.0:
resolved "https://registry.yarnpkg.com/lodash.xor/-/lodash.xor-4.5.0.tgz#4d48ed7e98095b0632582ba714d3ff8ae8fb1db6"
integrity sha512-sVN2zimthq7aZ5sPGXnSz32rZPuqcparVW50chJQe+mzTYV+IsxSsl/2gnkWWE2Of7K3myBQBqtLKOUEHJKRsQ==
lodash@4.17.21, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.21, lodash@^4.17.3:
lodash@4.17.21, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.3:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@ -10875,7 +11067,7 @@ memory-pager@^1.0.2:
resolved "https://registry.yarnpkg.com/memory-pager/-/memory-pager-1.5.0.tgz#d8751655d22d384682741c972f2c3d6dfa3e66b5"
integrity sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==
memorystream@0.3.1:
memorystream@0.3.1, memorystream@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2"
integrity sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==
@ -10900,7 +11092,7 @@ methods@^1.1.2:
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
micromatch@^3.1.10, micromatch@^3.1.4:
micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.5:
version "3.1.10"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==
@ -13096,6 +13288,21 @@ relative-microtime@^2.0.0:
resolved "https://registry.yarnpkg.com/relative-microtime/-/relative-microtime-2.0.0.tgz#cceed2af095ecd72ea32011279c79e5fcc7de29b"
integrity sha512-l18ha6HEZc+No/uK4GyAnNxgKW7nvEe35IaeN54sShMojtqik2a6GbTyuiezkjpPaqP874Z3lW5ysBo5irz4NA==
relative@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/relative/-/relative-3.0.2.tgz#0dcd8ec54a5d35a3c15e104503d65375b5a5367f"
integrity sha512-Q5W2qeYtY9GbiR8z1yHNZ1DGhyjb4AnLEjt8iE6XfcC1QIu+FAtj3HQaO0wH28H1mX6cqNLvAqWhP402dxJGyA==
dependencies:
isobject "^2.0.0"
remarkable@^1.6.2:
version "1.7.4"
resolved "https://registry.yarnpkg.com/remarkable/-/remarkable-1.7.4.tgz#19073cb960398c87a7d6546eaa5e50d2022fcd00"
integrity sha512-e6NKUXgX95whv7IgddywbeN/ItCkWbISmc2DiqHJb0wTrqZIexqdco5b8Z3XZoo/48IdNVKM9ZCvTPJ4F5uvhg==
dependencies:
argparse "^1.0.10"
autolinker "~0.28.0"
remove-trailing-separator@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
@ -13444,6 +13651,11 @@ seek-bzip@^1.0.5:
dependencies:
commander "^2.8.1"
self-closing-tags@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/self-closing-tags/-/self-closing-tags-1.0.1.tgz#6c5fa497994bb826b484216916371accee490a5d"
integrity sha512-7t6hNbYMxM+VHXTgJmxwgZgLGktuXtVVD5AivWzNTdJBM4DBjnDKDzkf2SrNjihaArpeJYNjxkELBu1evI4lQA==
semver-compare@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
@ -14232,6 +14444,11 @@ strip-outer@^1.0.0:
dependencies:
escape-string-regexp "^1.0.2"
striptags@^3.1.1:
version "3.2.0"
resolved "https://registry.yarnpkg.com/striptags/-/striptags-3.2.0.tgz#cc74a137db2de8b0b9a370006334161f7dd67052"
integrity sha512-g45ZOGzHDMe2bdYMdIvdAfCQkCTDMGBazSw1ypMowwGIee7ZQ5dU0rBJ8Jqgl+jAKIv4dbeE1jscZq9wid1Tkw==
style-loader@^3.3.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.1.tgz#057dfa6b3d4d7c7064462830f9113ed417d38575"
@ -14716,6 +14933,11 @@ to-fast-properties@^2.0.0:
resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
to-gfm-code-block@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/to-gfm-code-block/-/to-gfm-code-block-0.1.1.tgz#25d045a5fae553189e9637b590900da732d8aa82"
integrity sha512-LQRZWyn8d5amUKnfR9A9Uu7x9ss7Re8peuWR2gkh1E+ildOfv2aF26JpuDg8JtvCduu5+hOrMIH+XstZtnagqg==
to-json-schema@0.2.5:
version "0.2.5"
resolved "https://registry.yarnpkg.com/to-json-schema/-/to-json-schema-0.2.5.tgz#ef3c3f11ad64460dcfbdbafd0fd525d69d62a98f"
@ -14984,6 +15206,13 @@ typedarray-to-buffer@^3.1.5:
dependencies:
is-typedarray "^1.0.0"
typeof-article@^0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/typeof-article/-/typeof-article-0.1.1.tgz#9f07e733c3fbb646ffa9e61c08debacd460e06af"
integrity sha512-Vn42zdX3FhmUrzEmitX3iYyLb+Umwpmv8fkZRIknYh84lmdrwqZA5xYaoKiIj2Rc5i/5wcDrpUmZcbk1U51vTw==
dependencies:
kind-of "^3.1.0"
typeof@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/typeof/-/typeof-1.0.0.tgz#9c84403f2323ae5399167275497638ea1d2f2440"
@ -15344,6 +15573,14 @@ vm2@3.9.11:
acorn "^8.7.0"
acorn-walk "^8.2.0"
vm2@^3.9.4:
version "3.9.14"
resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.9.14.tgz#964042b474cf1e6e4f475a39144773cdb9deb734"
integrity sha512-HgvPHYHeQy8+QhzlFryvSteA4uQLBCOub02mgqdR+0bN/akRZ48TGB1v0aCv7ksyc0HXx16AZtMHKS38alc6TA==
dependencies:
acorn "^8.7.0"
acorn-walk "^8.2.0"
vuvuzela@1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/vuvuzela/-/vuvuzela-1.0.3.tgz#3be145e58271c73ca55279dd851f12a682114b0b"
@ -15905,6 +16142,11 @@ yauzl@^2.4.2:
buffer-crc32 "~0.2.3"
fd-slicer "~1.1.0"
year@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/year/-/year-0.2.1.tgz#4083ae520a318b23ec86037f3000cb892bdf9bb0"
integrity sha512-9GnJUZ0QM4OgXuOzsKNzTJ5EOkums1Xc+3YQXp+Q+UxFjf7zLucp9dQ8QMIft0Szs1E1hUiXFim1OYfEKFq97w==
ylru@^1.2.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.3.2.tgz#0de48017473275a4cbdfc83a1eaf67c01af8a785"

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@budibase/types",
"version": "2.3.18-alpha.14",
"version": "2.3.18-alpha.17",
"description": "Budibase types",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View file

@ -16,6 +16,7 @@ export interface BulkUserRequest {
userIds: string[]
}
create?: {
roles?: any[]
users: User[]
groups: any[]
}
@ -49,7 +50,7 @@ export interface SearchUsersRequest {
page?: string
email?: string
appId?: string
userIds?: string[]
paginated?: boolean
}
export interface CreateAdminUserRequest {

View file

@ -36,6 +36,9 @@ export interface SettingsConfig extends Config {
config: SettingsInnerConfig
}
export type SSOConfigType = ConfigType.GOOGLE | ConfigType.OIDC
export type SSOConfig = GoogleInnerConfig | OIDCInnerConfig
export interface GoogleInnerConfig {
clientID: string
clientSecret: string
@ -60,6 +63,10 @@ export interface OIDCStrategyConfiguration {
callbackURL: string
}
export interface OIDCConfigs {
configs: OIDCInnerConfig[]
}
export interface OIDCInnerConfig {
configUrl: string
clientID: string
@ -72,9 +79,7 @@ export interface OIDCInnerConfig {
}
export interface OIDCConfig extends Config {
config: {
configs: OIDCInnerConfig[]
}
config: OIDCConfigs
}
export interface OIDCWellKnownConfig {

View file

@ -1,7 +1,7 @@
{
"name": "@budibase/worker",
"email": "hi@budibase.com",
"version": "2.3.18-alpha.14",
"version": "2.3.18-alpha.17",
"description": "Budibase background service",
"main": "src/index.ts",
"repository": {
@ -36,10 +36,10 @@
"author": "Budibase",
"license": "GPL-3.0",
"dependencies": {
"@budibase/backend-core": "2.3.18-alpha.14",
"@budibase/pro": "2.3.18-alpha.14",
"@budibase/string-templates": "2.3.18-alpha.14",
"@budibase/types": "2.3.18-alpha.14",
"@budibase/backend-core": "2.3.18-alpha.17",
"@budibase/pro": "2.3.18-alpha.17",
"@budibase/string-templates": "2.3.18-alpha.17",
"@budibase/types": "2.3.18-alpha.17",
"@koa/router": "8.0.8",
"@sentry/node": "6.17.7",
"@techpass/passport-openidconnect": "0.3.2",

View file

@ -17,10 +17,15 @@ import {
Ctx,
GetPublicOIDCConfigResponse,
GetPublicSettingsResponse,
GoogleInnerConfig,
isGoogleConfig,
isOIDCConfig,
isSettingsConfig,
isSMTPConfig,
OIDCConfigs,
SettingsInnerConfig,
SSOConfig,
SSOConfigType,
UserCtx,
} from "@budibase/types"
import * as pro from "@budibase/pro"
@ -119,6 +124,61 @@ const getEventFns = async (config: Config, existing?: Config) => {
return fns
}
type SSOConfigs = { [key in SSOConfigType]: SSOConfig | undefined }
async function getSSOConfigs(): Promise<SSOConfigs> {
const google = await configs.getGoogleConfig()
const oidc = await configs.getOIDCConfig()
return {
[ConfigType.GOOGLE]: google,
[ConfigType.OIDC]: oidc,
}
}
async function hasActivatedConfig(ssoConfigs?: SSOConfigs) {
if (!ssoConfigs) {
ssoConfigs = await getSSOConfigs()
}
return !!Object.values(ssoConfigs).find(c => c?.activated)
}
async function verifySettingsConfig(config: SettingsInnerConfig) {
if (config.isSSOEnforced) {
const valid = await hasActivatedConfig()
if (!valid) {
throw new Error("Cannot enforce SSO without an activated configuration")
}
}
}
async function verifySSOConfig(type: SSOConfigType, config: SSOConfig) {
const settings = await configs.getSettingsConfig()
if (settings.isSSOEnforced && !config.activated) {
// config is being saved as deactivated
// ensure there is at least one other activated sso config
const ssoConfigs = await getSSOConfigs()
// overwrite the config being updated
// to reflect the desired state
ssoConfigs[type] = config
const activated = await hasActivatedConfig(ssoConfigs)
if (!activated) {
throw new Error(
"Configuration cannot be deactivated while SSO is enforced"
)
}
}
}
async function verifyGoogleConfig(config: GoogleInnerConfig) {
await verifySSOConfig(ConfigType.GOOGLE, config)
}
async function verifyOIDCConfig(config: OIDCConfigs) {
await verifySSOConfig(ConfigType.OIDC, config.configs[0])
}
export async function save(ctx: UserCtx<Config>) {
const body = ctx.request.body
const type = body.type
@ -133,10 +193,19 @@ export async function save(ctx: UserCtx<Config>) {
try {
// verify the configuration
switch (config.type) {
switch (type) {
case ConfigType.SMTP:
await email.verifyConfig(config)
break
case ConfigType.SETTINGS:
await verifySettingsConfig(config)
break
case ConfigType.GOOGLE:
await verifyGoogleConfig(config)
break
case ConfigType.OIDC:
await verifyOIDCConfig(config)
break
}
} catch (err: any) {
ctx.throw(400, err)

View file

@ -1,4 +1,9 @@
import { checkInviteCode } from "../../../utilities/redis"
import {
checkInviteCode,
getInviteCodes,
updateInviteCode,
} from "../../../utilities/redis"
// import sdk from "../../../sdk"
import * as userSdk from "../../../sdk/users"
import env from "../../../environment"
import {
@ -28,6 +33,7 @@ import {
platform,
} from "@budibase/backend-core"
import { checkAnyUserExists } from "../../../utilities/users"
import { isEmailConfigured } from "../../../utilities/email"
const MAX_USERS_UPLOAD_LIMIT = 1000
@ -179,16 +185,28 @@ export const destroy = async (ctx: any) => {
}
}
export const getAppUsers = async (ctx: any) => {
const body = ctx.request.body as SearchUsersRequest
const users = await userSdk.getUsersByAppAccess(body?.appId)
ctx.body = { data: users }
}
export const search = async (ctx: any) => {
const body = ctx.request.body as SearchUsersRequest
const paginated = await userSdk.paginatedUsers(body)
// user hashed password shouldn't ever be returned
for (let user of paginated.data) {
if (user) {
delete user.password
if (body.paginated === false) {
await getAppUsers(ctx)
} else {
const paginated = await userSdk.paginatedUsers(body)
// user hashed password shouldn't ever be returned
for (let user of paginated.data) {
if (user) {
delete user.password
}
}
ctx.body = paginated
}
ctx.body = paginated
}
// called internally by app server user fetch
@ -218,9 +236,71 @@ export const tenantUserLookup = async (ctx: any) => {
}
}
/*
Encapsulate the app user onboarding flows here.
*/
export const onboardUsers = async (ctx: any) => {
const request = ctx.request.body as InviteUsersRequest | BulkUserRequest
const isBulkCreate = "create" in request
const emailConfigured = await isEmailConfigured()
let onboardingResponse
if (isBulkCreate) {
// @ts-ignore
const { users, groups, roles } = request.create
const assignUsers = users.map((user: User) => (user.roles = roles))
onboardingResponse = await userSdk.bulkCreate(assignUsers, groups)
ctx.body = onboardingResponse
} else if (emailConfigured) {
onboardingResponse = await inviteMultiple(ctx)
} else if (!emailConfigured) {
const inviteRequest = ctx.request.body as InviteUsersRequest
let createdPasswords: any = {}
const users: User[] = inviteRequest.map(invite => {
let password = Math.random().toString(36).substring(2, 22)
// Temp password to be passed to the user.
createdPasswords[invite.email] = password
return {
email: invite.email,
password,
forceResetPassword: true,
roles: invite.userInfo.apps,
admin: { global: false },
builder: { global: false },
tenantId: tenancy.getTenantId(),
}
})
let bulkCreateReponse = await userSdk.bulkCreate(users, [])
// Apply temporary credentials
let createWithCredentials = {
...bulkCreateReponse,
successful: bulkCreateReponse?.successful.map(user => {
return {
...user,
password: createdPasswords[user.email],
}
}),
created: true,
}
ctx.body = createWithCredentials
} else {
ctx.throw(400, "User onboarding failed")
}
}
export const invite = async (ctx: any) => {
const request = ctx.request.body as InviteUserRequest
const response = await userSdk.invite([request])
let multiRequest = [request] as InviteUsersRequest
const response = await userSdk.invite(multiRequest)
// explicitly throw for single user invite
if (response.unsuccessful.length) {
@ -234,6 +314,8 @@ export const invite = async (ctx: any) => {
ctx.body = {
message: "Invitation has been sent.",
successful: response.successful,
unsuccessful: response.unsuccessful,
}
}
@ -255,6 +337,53 @@ export const checkInvite = async (ctx: any) => {
}
}
export const getUserInvites = async (ctx: any) => {
let invites
try {
// Restricted to the currently authenticated tenant
invites = await getInviteCodes([ctx.user.tenantId])
} catch (e) {
ctx.throw(400, "There was a problem fetching invites")
}
ctx.body = invites
}
export const updateInvite = async (ctx: any) => {
const { code } = ctx.params
let updateBody = { ...ctx.request.body }
delete updateBody.email
let invite
try {
invite = await checkInviteCode(code, false)
if (!invite) {
throw new Error("The invite could not be retrieved")
}
} catch (e) {
ctx.throw(400, "There was a problem with the invite")
}
let updated = {
...invite,
}
if (!updateBody?.apps || !Object.keys(updateBody?.apps).length) {
updated.info.apps = []
} else {
updated.info = {
...invite.info,
apps: {
...invite.info.apps,
...updateBody.apps,
},
}
}
await updateInviteCode(code, updated)
ctx.body = { ...invite }
}
export const inviteAccept = async (
ctx: Ctx<AcceptUserInviteRequest, AcceptUserInviteResponse>
) => {
@ -263,13 +392,23 @@ export const inviteAccept = async (
// info is an extension of the user object that was stored by global
const { email, info }: any = await checkInviteCode(inviteCode)
const user = await tenancy.doInTenant(info.tenantId, async () => {
const saved = await userSdk.save({
let request = {
firstName,
lastName,
password,
email,
roles: info.apps,
tenantId: info.tenantId,
}
delete info.apps
request = {
...request,
...info,
})
}
const saved = await userSdk.save(request)
const db = tenancy.getGlobalDB()
const user = await db.get(saved._id)
await events.user.inviteAccepted(user)

View file

@ -34,8 +34,8 @@ function settingValidation() {
function googleValidation() {
// prettier-ignore
return Joi.object({
clientID: Joi.when('activated', { is: true, then: Joi.string().required() }),
clientSecret: Joi.when('activated', { is: true, then: Joi.string().required() }),
clientID: Joi.string().required(),
clientSecret: Joi.string().required(),
activated: Joi.boolean().required(),
}).unknown(true)
}
@ -45,12 +45,12 @@ function oidcValidation() {
return Joi.object({
configs: Joi.array().items(
Joi.object({
clientID: Joi.when('activated', { is: true, then: Joi.string().required() }),
clientSecret: Joi.when('activated', { is: true, then: Joi.string().required() }),
configUrl: Joi.when('activated', { is: true, then: Joi.string().required() }),
clientID: Joi.string().required(),
clientSecret: Joi.string().required(),
configUrl: Joi.string().required(),
logo: Joi.string().allow("", null),
name: Joi.string().allow("", null),
uuid: Joi.when('activated', { is: true, then: Joi.string().required() }),
uuid: Joi.string().required(),
activated: Joi.boolean().required(),
scopes: Joi.array().optional()
})

View file

@ -30,7 +30,11 @@ describe("/api/global/users", () => {
email
)
expect(res.body).toEqual({ message: "Invitation has been sent." })
expect(res.body?.message).toBe("Invitation has been sent.")
expect(res.body?.unsuccessful.length).toBe(0)
expect(res.body?.successful.length).toBe(1)
expect(res.body?.successful[0].email).toBe(email)
expect(sendMailMock).toHaveBeenCalled()
expect(code).toBeDefined()
expect(events.user.invited).toBeCalledTimes(1)

View file

@ -38,13 +38,6 @@ function buildInviteMultipleValidation() {
))
}
function buildInviteLookupValidation() {
// prettier-ignore
return auth.joiValidator.params(Joi.object({
code: Joi.string().required()
}).unknown(true))
}
const createUserAdminOnly = (ctx: any, next: any) => {
if (!ctx.request.body._id) {
return auth.adminOnly(ctx, next)
@ -88,22 +81,34 @@ router
.get("/api/global/roles/:appId")
.post(
"/api/global/users/invite",
auth.adminOnly,
auth.builderOrAdmin,
buildInviteValidation(),
controller.invite
)
.post(
"/api/global/users/onboard",
auth.builderOrAdmin,
buildInviteMultipleValidation(),
controller.onboardUsers
)
.post(
"/api/global/users/multi/invite",
auth.adminOnly,
auth.builderOrAdmin,
buildInviteMultipleValidation(),
controller.inviteMultiple
)
// non-global endpoints
.get("/api/global/users/invite/:code", controller.checkInvite)
.post(
"/api/global/users/invite/update/:code",
auth.builderOrAdmin,
controller.updateInvite
)
.get(
"/api/global/users/invite/:code",
buildInviteLookupValidation(),
controller.checkInvite
"/api/global/users/invites",
auth.builderOrAdmin,
controller.getUserInvites
)
.post(
"/api/global/users/invite/accept",

View file

@ -57,11 +57,22 @@ export const countUsersByApp = async (appId: string) => {
}
}
export const getUsersByAppAccess = async (appId?: string) => {
const opts: any = {
include_docs: true,
limit: 50,
}
let response: User[] = await usersCore.searchGlobalUsersByAppAccess(
appId,
opts
)
return response
}
export const paginatedUsers = async ({
page,
email,
appId,
userIds,
}: SearchUsersRequest = {}) => {
const db = tenancy.getGlobalDB()
// get one extra document, to have the next page
@ -234,7 +245,7 @@ export const save = async (
const tenantId = tenancy.getTenantId()
const db = tenancy.getGlobalDB()
let { email, _id, userGroups = [] } = user
let { email, _id, userGroups = [], roles } = user
if (!email && !_id) {
throw new Error("_id or email is required")
@ -276,6 +287,10 @@ export const save = async (
builtUser.roles = dbUser.roles
}
if (!dbUser && roles?.length) {
builtUser.roles = { ...roles }
}
// make sure we set the _id field for a new user
// Also if this is a new user, associate groups with them
let groupPromises = []

View file

@ -203,7 +203,7 @@ export async function sendEmail(
* @param {object} config an SMTP configuration - this is based on the nodemailer API.
* @return {Promise<boolean>} returns true if the configuration is valid.
*/
export async function verifyConfig(config: any) {
export async function verifyConfig(config: SMTPInnerConfig) {
const transport = createSMTPTransport(config)
await transport.verify()
}

View file

@ -7,7 +7,7 @@ function getExpirySecondsForDB(db: string) {
return 3600
case redis.utils.Databases.INVITATIONS:
// a day
return 86400
return 604800
}
}
@ -29,6 +29,20 @@ async function writeACode(db: string, value: any) {
return code
}
async function updateACode(db: string, code: string, value: any) {
const client = await getClient(db)
await client.store(code, value, getExpirySecondsForDB(db))
}
/**
* Given an invite code and invite body, allow the update an existing/valid invite in redis
* @param {string} inviteCode The invite code for an invite in redis
* @param {object} value The body of the updated user invitation
*/
export async function updateInviteCode(inviteCode: string, value: string) {
await updateACode(redis.utils.Databases.INVITATIONS, inviteCode, value)
}
async function getACode(db: string, code: string, deleteCode = true) {
const client = await getClient(db)
const value = await client.get(code)
@ -113,3 +127,27 @@ export async function checkInviteCode(
throw "Invitation is not valid or has expired, please request a new one."
}
}
/**
Get all currently available user invitations.
@return {Object[]} A list of all objects containing invite metadata
**/
export async function getInviteCodes(tenantIds?: string[]) {
const client = await getClient(redis.utils.Databases.INVITATIONS)
const invites: any[] = await client.scan()
const results = invites.map(invite => {
return {
...invite.value,
code: invite.key,
}
})
return results.reduce((acc, invite) => {
if (tenantIds?.length && tenantIds.includes(invite.info.tenantId)) {
acc.push(invite)
} else {
acc.push(invite)
}
return acc
}, [])
}

File diff suppressed because it is too large Load diff