Builder user onboarding
This commit is contained in:
parent
c135a029f9
commit
61ed62e6c4
|
@ -5,6 +5,7 @@ import {
|
||||||
generateAppUserID,
|
generateAppUserID,
|
||||||
queryGlobalView,
|
queryGlobalView,
|
||||||
UNICODE_MAX,
|
UNICODE_MAX,
|
||||||
|
directCouchFind,
|
||||||
} from "./db"
|
} from "./db"
|
||||||
import { BulkDocsResponse, User } from "@budibase/types"
|
import { BulkDocsResponse, User } from "@budibase/types"
|
||||||
import { getGlobalDB } from "./context"
|
import { getGlobalDB } from "./context"
|
||||||
|
@ -64,12 +65,52 @@ export const searchGlobalUsersByApp = async (appId: any, opts: any) => {
|
||||||
})
|
})
|
||||||
params.startkey = opts && opts.startkey ? opts.startkey : params.startkey
|
params.startkey = opts && opts.startkey ? opts.startkey : params.startkey
|
||||||
let response = await queryGlobalView(ViewName.USER_BY_APP, params)
|
let response = await queryGlobalView(ViewName.USER_BY_APP, params)
|
||||||
|
|
||||||
if (!response) {
|
if (!response) {
|
||||||
response = []
|
response = []
|
||||||
}
|
}
|
||||||
return Array.isArray(response) ? response : [response]
|
return Array.isArray(response) ? response : [response]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
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) => {
|
export const getGlobalUserByAppPage = (appId: string, user: User) => {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
<script>
|
<script>
|
||||||
import "@spectrum-css/actionbutton/dist/index-vars.css"
|
import "@spectrum-css/actionbutton/dist/index-vars.css"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
|
import Tooltip from "../Tooltip/Tooltip.svelte"
|
||||||
|
import { fade } from "svelte/transition"
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
export let quiet = false
|
export let quiet = false
|
||||||
|
@ -13,6 +16,9 @@
|
||||||
export let active = false
|
export let active = false
|
||||||
export let fullWidth = false
|
export let fullWidth = false
|
||||||
export let noPadding = false
|
export let noPadding = false
|
||||||
|
export let tooltip = ""
|
||||||
|
|
||||||
|
let showTooltip = false
|
||||||
|
|
||||||
function longPress(element) {
|
function longPress(element) {
|
||||||
if (!longPressable) return
|
if (!longPressable) return
|
||||||
|
@ -35,42 +41,54 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<span
|
||||||
use:longPress
|
class="btn-wrap"
|
||||||
class:spectrum-ActionButton--quiet={quiet}
|
on:mouseover={() => (showTooltip = true)}
|
||||||
class:spectrum-ActionButton--emphasized={emphasized}
|
on:mouseleave={() => (showTooltip = false)}
|
||||||
class:is-selected={selected}
|
on:focus={() => (showTooltip = true)}
|
||||||
class:noPadding
|
|
||||||
class:fullWidth
|
|
||||||
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
|
|
||||||
class:active
|
|
||||||
{disabled}
|
|
||||||
on:longPress
|
|
||||||
on:click|preventDefault
|
|
||||||
>
|
>
|
||||||
{#if longPressable}
|
<button
|
||||||
<svg
|
use:longPress
|
||||||
class="spectrum-Icon spectrum-UIIcon-CornerTriangle100 spectrum-ActionButton-hold"
|
class:spectrum-ActionButton--quiet={quiet}
|
||||||
focusable="false"
|
class:spectrum-ActionButton--emphasized={emphasized}
|
||||||
aria-hidden="true"
|
class:is-selected={selected}
|
||||||
>
|
class:noPadding
|
||||||
<use xlink:href="#spectrum-css-icon-CornerTriangle100" />
|
class:fullWidth
|
||||||
</svg>
|
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
|
||||||
{/if}
|
class:active
|
||||||
{#if icon}
|
{disabled}
|
||||||
<svg
|
on:longPress
|
||||||
class="spectrum-Icon spectrum-Icon--size{size}"
|
on:click|preventDefault
|
||||||
focusable="false"
|
>
|
||||||
aria-hidden="true"
|
{#if longPressable}
|
||||||
aria-label={icon}
|
<svg
|
||||||
>
|
class="spectrum-Icon spectrum-UIIcon-CornerTriangle100 spectrum-ActionButton-hold"
|
||||||
<use xlink:href="#spectrum-icon-18-{icon}" />
|
focusable="false"
|
||||||
</svg>
|
aria-hidden="true"
|
||||||
{/if}
|
>
|
||||||
{#if $$slots}
|
<use xlink:href="#spectrum-css-icon-CornerTriangle100" />
|
||||||
<span class="spectrum-ActionButton-label"><slot /></span>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
{#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>
|
<style>
|
||||||
.fullWidth {
|
.fullWidth {
|
||||||
|
@ -98,4 +116,14 @@
|
||||||
.is-selected:not(.emphasized) .spectrum-Icon {
|
.is-selected:not(.emphasized) .spectrum-Icon {
|
||||||
color: var(--spectrum-global-color-gray-900);
|
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>
|
</style>
|
||||||
|
|
|
@ -41,7 +41,7 @@
|
||||||
const getFieldText = (value, options, placeholder) => {
|
const getFieldText = (value, options, placeholder) => {
|
||||||
// Always use placeholder if no value
|
// Always use placeholder if no value
|
||||||
if (value == null || value === "") {
|
if (value == null || value === "") {
|
||||||
return placeholder || "Choose an option"
|
return placeholder !== false ? "Choose an option" : ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return getFieldAttribute(getOptionLabel, value, options)
|
return getFieldAttribute(getOptionLabel, value, options)
|
||||||
|
@ -74,7 +74,7 @@
|
||||||
{autocomplete}
|
{autocomplete}
|
||||||
{sort}
|
{sort}
|
||||||
isPlaceholder={value == null || value === ""}
|
isPlaceholder={value == null || value === ""}
|
||||||
placeholderOption={placeholder}
|
placeholderOption={placeholder === false ? null : placeholder}
|
||||||
isOptionSelected={option => option === value}
|
isOptionSelected={option => option === value}
|
||||||
onSelectOption={selectOption}
|
onSelectOption={selectOption}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -6,8 +6,10 @@
|
||||||
Heading,
|
Heading,
|
||||||
Body,
|
Body,
|
||||||
Button,
|
Button,
|
||||||
Icon,
|
ActionButton,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
|
import RevertModal from "components/deploy/RevertModal.svelte"
|
||||||
|
import VersionModal from "components/deploy/VersionModal.svelte"
|
||||||
import { processStringSync } from "@budibase/string-templates"
|
import { processStringSync } from "@budibase/string-templates"
|
||||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||||
import analytics, { Events, EventSource } from "analytics"
|
import analytics, { Events, EventSource } from "analytics"
|
||||||
|
@ -16,6 +18,8 @@
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import DeployModal from "components/deploy/DeployModal.svelte"
|
import DeployModal from "components/deploy/DeployModal.svelte"
|
||||||
import { apps } from "stores/portal"
|
import { apps } from "stores/portal"
|
||||||
|
import { store } from "builderStore"
|
||||||
|
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
|
||||||
|
|
||||||
export let application
|
export let application
|
||||||
|
|
||||||
|
@ -108,66 +112,93 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="deployment-top-nav">
|
<div class="action-top-nav">
|
||||||
{#if isPublished}
|
<div class="action-buttons">
|
||||||
<div class="publish-popover">
|
<div class="version">
|
||||||
<div bind:this={publishPopoverAnchor}>
|
<VersionModal />
|
||||||
<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>
|
</div>
|
||||||
{/if}
|
<RevertModal />
|
||||||
|
|
||||||
{#if !isPublished}
|
{#if isPublished}
|
||||||
<Icon
|
<div class="publish-popover">
|
||||||
size="M"
|
<div bind:this={publishPopoverAnchor}>
|
||||||
name="GlobeStrike"
|
<ActionButton
|
||||||
disabled
|
quiet
|
||||||
tooltip="Your app has not been published yet"
|
icon="Globe"
|
||||||
/>
|
size="M"
|
||||||
{/if}
|
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={`builder-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>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
bind:this={unpublishModal}
|
bind:this={unpublishModal}
|
||||||
title="Confirm unpublish"
|
title="Confirm unpublish"
|
||||||
|
@ -183,6 +214,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* .banner-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
} */
|
||||||
.popover-content {
|
.popover-content {
|
||||||
padding: var(--spacing-xl);
|
padding: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
@ -191,6 +227,22 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
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>
|
</style>
|
|
@ -1,10 +1,10 @@
|
||||||
<script>
|
<script>
|
||||||
import {
|
import {
|
||||||
Icon,
|
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
notifications,
|
notifications,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
|
ActionButton,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { store } from "builderStore"
|
import { store } from "builderStore"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
|
@ -28,12 +28,14 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Icon
|
<ActionButton
|
||||||
name="Revert"
|
quiet
|
||||||
hoverable
|
icon="Revert"
|
||||||
on:click={revertModal.show}
|
size="M"
|
||||||
tooltip="Revert changes"
|
tooltip="Revert changes"
|
||||||
|
on:click={revertModal.show}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal bind:this={revertModal}>
|
<Modal bind:this={revertModal}>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
title="Revert Changes"
|
title="Revert Changes"
|
||||||
|
|
|
@ -122,7 +122,9 @@
|
||||||
<Layout noPadding gap="M">
|
<Layout noPadding gap="M">
|
||||||
<div class="tour-header">
|
<div class="tour-header">
|
||||||
<Heading size="XS">{tourStep?.title || "-"}</Heading>
|
<Heading size="XS">{tourStep?.title || "-"}</Heading>
|
||||||
<div>{`${tourStepIdx + 1}/${tourSteps?.length}`}</div>
|
{#if tourSteps?.length > 1}
|
||||||
|
<div>{`${tourStepIdx + 1}/${tourSteps?.length}`}</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Body size="S">
|
<Body size="S">
|
||||||
<span class="tour-body">
|
<span class="tour-body">
|
||||||
|
|
|
@ -9,11 +9,14 @@ export const TOUR_STEP_KEYS = {
|
||||||
BUILDER_APP_PUBLISH: "builder-app-publish",
|
BUILDER_APP_PUBLISH: "builder-app-publish",
|
||||||
BUILDER_DATA_SECTION: "builder-data-section",
|
BUILDER_DATA_SECTION: "builder-data-section",
|
||||||
BUILDER_DESIGN_SECTION: "builder-design-section",
|
BUILDER_DESIGN_SECTION: "builder-design-section",
|
||||||
|
BUILDER_USER_MANAGEMENT: "builder-user-management",
|
||||||
BUILDER_AUTOMATE_SECTION: "builder-automate-section",
|
BUILDER_AUTOMATE_SECTION: "builder-automate-section",
|
||||||
|
FEATURE_USER_MANAGEMENT: "feature-user-management",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TOUR_KEYS = {
|
export const TOUR_KEYS = {
|
||||||
TOUR_BUILDER_ONBOARDING: "builder-onboarding",
|
TOUR_BUILDER_ONBOARDING: "builder-onboarding",
|
||||||
|
FEATURE_ONBOARDING: "feature-onboarding",
|
||||||
}
|
}
|
||||||
|
|
||||||
const tourEvent = eventKey => {
|
const tourEvent = eventKey => {
|
||||||
|
@ -58,6 +61,15 @@ const getTours = () => {
|
||||||
},
|
},
|
||||||
align: "left",
|
align: "left",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT,
|
||||||
|
title: "Users",
|
||||||
|
query: ".toprightnav #builder-app-users-button",
|
||||||
|
body: "Choose which users you want to see to have access 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,
|
id: TOUR_STEP_KEYS.BUILDER_APP_PUBLISH,
|
||||||
title: "Publish",
|
title: "Publish",
|
||||||
|
@ -90,6 +102,18 @@ const getTours = () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[TOUR_KEYS.FEATURE_ONBOARDING]: [
|
||||||
|
{
|
||||||
|
id: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT,
|
||||||
|
title: "Users",
|
||||||
|
query: ".toprightnav #builder-app-users-button",
|
||||||
|
body: "Choose which users you want to have access to your app and control what level of access they have.",
|
||||||
|
onLoad: () => {
|
||||||
|
tourEvent(TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT)
|
||||||
|
},
|
||||||
|
align: "left",
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,735 @@
|
||||||
|
<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"
|
||||||
|
|
||||||
|
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 appRole = Object.keys(user.roles).find(x => x === prodAppId)
|
||||||
|
|
||||||
|
// if (
|
||||||
|
// !appRole &&
|
||||||
|
// user.userGroups &&
|
||||||
|
// !user.builder?.global &&
|
||||||
|
// !user.admin.global
|
||||||
|
// ) {
|
||||||
|
// console.log("Hi, I don't have groups > ", user.email)
|
||||||
|
// }
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
role: !appRole ? undefined : user.roles[appRole],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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)
|
||||||
|
|
||||||
|
// $: enrichedAppGroupsById = getAppGroups(enrichedGroups, prodAppId).map(
|
||||||
|
// group => group._id
|
||||||
|
// )
|
||||||
|
// $: console.log("ALL GROUP IDS ", enrichedAppGroupsById)
|
||||||
|
|
||||||
|
$: 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 roleNote = user => {
|
||||||
|
if (user.group) {
|
||||||
|
return "Part of a group"
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{#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)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
note={roleNote(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)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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.muted :global(.spectrum-Picker-label),
|
||||||
|
.auth-entity-access.muted :global(.spectrum-StatusLight) {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 65% auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-entity .details {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-entity .user-email {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#builder-side-panel-container {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: calc(100vw - 40px);
|
||||||
|
background: var(--background);
|
||||||
|
border-left: var(--border-light);
|
||||||
|
z-index: 3;
|
||||||
|
padding: var(--spacing-xl) 0px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xl);
|
||||||
|
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: 1px solid var(--spectrum-alias-border-color-mid);
|
||||||
|
border-bottom: 1px solid var(--spectrum-alias-border-color-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
#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 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-header {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -13,15 +13,14 @@
|
||||||
notifications,
|
notifications,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
|
|
||||||
import RevertModal from "components/deploy/RevertModal.svelte"
|
import AppActions from "components/deploy/AppActions.svelte"
|
||||||
import VersionModal from "components/deploy/VersionModal.svelte"
|
|
||||||
import DeployNavigation from "components/deploy/DeployNavigation.svelte"
|
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import { isActive, goto, layout, redirect } from "@roxi/routify"
|
import { isActive, goto, layout, redirect } from "@roxi/routify"
|
||||||
import { capitalise } from "helpers"
|
import { capitalise } from "helpers"
|
||||||
import { onMount, onDestroy } from "svelte"
|
import { onMount, onDestroy } from "svelte"
|
||||||
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
|
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
|
||||||
import TourPopover from "components/portal/onboarding/TourPopover.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"
|
import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js"
|
||||||
|
|
||||||
export let application
|
export let application
|
||||||
|
@ -116,6 +115,11 @@
|
||||||
<div class="loading" />
|
<div class="loading" />
|
||||||
{:then _}
|
{:then _}
|
||||||
<TourPopover />
|
<TourPopover />
|
||||||
|
|
||||||
|
{#if $store.builderSidePanel}
|
||||||
|
<BuilderSidePanel />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="root">
|
<div class="root">
|
||||||
<div class="top-nav">
|
<div class="top-nav">
|
||||||
<div class="topleftnav">
|
<div class="topleftnav">
|
||||||
|
@ -181,11 +185,7 @@
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<div class="toprightnav">
|
<div class="toprightnav">
|
||||||
<div class="version">
|
<AppActions {application} />
|
||||||
<VersionModal />
|
|
||||||
</div>
|
|
||||||
<RevertModal />
|
|
||||||
<DeployNavigation {application} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
|
@ -250,10 +250,6 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-xl);
|
gap: var(--spacing-l);
|
||||||
}
|
|
||||||
|
|
||||||
.version {
|
|
||||||
margin-right: var(--spacing-s);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -26,9 +26,15 @@ export function createUsersStore() {
|
||||||
return await API.getUsers()
|
return await API.getUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One or more users.
|
||||||
|
async function onboard(payload) {
|
||||||
|
return await API.onboardUsers(payload)
|
||||||
|
}
|
||||||
|
|
||||||
async function invite(payload) {
|
async function invite(payload) {
|
||||||
return API.inviteUsers(payload)
|
return API.inviteUsers(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acceptInvite(inviteCode, password, firstName, lastName) {
|
async function acceptInvite(inviteCode, password, firstName, lastName) {
|
||||||
return API.acceptInvite({
|
return API.acceptInvite({
|
||||||
inviteCode,
|
inviteCode,
|
||||||
|
@ -42,6 +48,14 @@ export function createUsersStore() {
|
||||||
return API.getUserInvite(inviteCode)
|
return API.getUserInvite(inviteCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getInvites() {
|
||||||
|
return API.getUserInvites()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateInvite(invite) {
|
||||||
|
return API.updateUserInvite(invite)
|
||||||
|
}
|
||||||
|
|
||||||
async function create(data) {
|
async function create(data) {
|
||||||
let mappedUsers = data.users.map(user => {
|
let mappedUsers = data.users.map(user => {
|
||||||
const body = {
|
const body = {
|
||||||
|
@ -106,8 +120,11 @@ export function createUsersStore() {
|
||||||
getUserRole,
|
getUserRole,
|
||||||
fetch,
|
fetch,
|
||||||
invite,
|
invite,
|
||||||
|
onboard,
|
||||||
acceptInvite,
|
acceptInvite,
|
||||||
fetchInvite,
|
fetchInvite,
|
||||||
|
getInvites,
|
||||||
|
updateInvite,
|
||||||
create,
|
create,
|
||||||
save,
|
save,
|
||||||
bulkDelete,
|
bulkDelete,
|
||||||
|
|
|
@ -12,8 +12,10 @@ export const buildUserEndpoints = API => ({
|
||||||
* Gets a list of users in the current tenant.
|
* Gets a list of users in the current tenant.
|
||||||
* @param {string} page The page to retrieve
|
* @param {string} page The page to retrieve
|
||||||
* @param {string} search The starts with string to search username/email by.
|
* @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 = {}
|
const opts = {}
|
||||||
if (page) {
|
if (page) {
|
||||||
opts.page = page
|
opts.page = page
|
||||||
|
@ -24,6 +26,9 @@ export const buildUserEndpoints = API => ({
|
||||||
if (appId) {
|
if (appId) {
|
||||||
opts.appId = appId
|
opts.appId = appId
|
||||||
}
|
}
|
||||||
|
if (typeof paginated === "boolean") {
|
||||||
|
opts.paginated = paginated
|
||||||
|
}
|
||||||
return await API.post({
|
return await API.post({
|
||||||
url: `/api/global/users/search`,
|
url: `/api/global/users/search`,
|
||||||
body: opts,
|
body: opts,
|
||||||
|
@ -133,7 +138,7 @@ export const buildUserEndpoints = API => ({
|
||||||
* @param builder whether the user should be a global builder
|
* @param builder whether the user should be a global builder
|
||||||
* @param admin whether the user should be a global admin
|
* @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({
|
return await API.post({
|
||||||
url: "/api/global/users/invite",
|
url: "/api/global/users/invite",
|
||||||
body: {
|
body: {
|
||||||
|
@ -141,11 +146,43 @@ export const buildUserEndpoints = API => ({
|
||||||
userInfo: {
|
userInfo: {
|
||||||
admin: admin ? { global: true } : undefined,
|
admin: admin ? { global: true } : undefined,
|
||||||
builder: builder ? { 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.
|
* Retrieves the invitation associated with a provided code.
|
||||||
* @param code The unique code for the target invite
|
* @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.
|
* Invites multiple users to the current tenant.
|
||||||
* @param users An array of users to invite
|
* @param users An array of users to invite
|
||||||
|
@ -169,6 +216,7 @@ export const buildUserEndpoints = API => ({
|
||||||
admin: user.admin ? { global: true } : undefined,
|
admin: user.admin ? { global: true } : undefined,
|
||||||
builder: user.admin || user.builder ? { global: true } : undefined,
|
builder: user.admin || user.builder ? { global: true } : undefined,
|
||||||
userGroups: user.groups,
|
userGroups: user.groups,
|
||||||
|
roles: user.apps ? user.apps : undefined,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
|
@ -35,6 +35,7 @@ export default class UserFetch extends DataFetch {
|
||||||
page: cursor,
|
page: cursor,
|
||||||
email: query.email,
|
email: query.email,
|
||||||
appId: query.appId,
|
appId: query.appId,
|
||||||
|
paginated: query.paginated,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
rows: res?.data || [],
|
rows: res?.data || [],
|
||||||
|
|
|
@ -50,7 +50,7 @@ export interface SearchUsersRequest {
|
||||||
page?: string
|
page?: string
|
||||||
email?: string
|
email?: string
|
||||||
appId?: string
|
appId?: string
|
||||||
userIds?: string[]
|
paginated?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAdminUserRequest {
|
export interface CreateAdminUserRequest {
|
||||||
|
|
|
@ -185,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) => {
|
export const search = async (ctx: any) => {
|
||||||
const body = ctx.request.body as SearchUsersRequest
|
const body = ctx.request.body as SearchUsersRequest
|
||||||
const paginated = await userSdk.paginatedUsers(body)
|
|
||||||
// user hashed password shouldn't ever be returned
|
if (body.paginated === false) {
|
||||||
for (let user of paginated.data) {
|
await getAppUsers(ctx)
|
||||||
if (user) {
|
} else {
|
||||||
delete user.password
|
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
|
// called internally by app server user fetch
|
||||||
|
@ -242,12 +254,18 @@ export const onboardUsers = async (ctx: any) => {
|
||||||
onboardingResponse = await userSdk.bulkCreate(assignUsers, groups)
|
onboardingResponse = await userSdk.bulkCreate(assignUsers, groups)
|
||||||
ctx.body = onboardingResponse
|
ctx.body = onboardingResponse
|
||||||
} else if (emailConfigured) {
|
} else if (emailConfigured) {
|
||||||
onboardingResponse = await inviteMultiple(ctx)
|
onboardingResponse = await invite(ctx)
|
||||||
} else if (!emailConfigured) {
|
} else if (!emailConfigured) {
|
||||||
const inviteRequest = ctx.request.body as InviteUsersRequest
|
const inviteRequest = ctx.request.body as InviteUsersRequest
|
||||||
|
|
||||||
|
let createdPasswords: any = {}
|
||||||
|
|
||||||
const users: User[] = inviteRequest.map(invite => {
|
const users: User[] = inviteRequest.map(invite => {
|
||||||
let password = Math.random().toString(36).substring(2, 22)
|
let password = Math.random().toString(36).substring(2, 22)
|
||||||
|
|
||||||
|
// Temp password to be passed to the user.
|
||||||
|
createdPasswords[invite.email] = password
|
||||||
|
|
||||||
return {
|
return {
|
||||||
email: invite.email,
|
email: invite.email,
|
||||||
password,
|
password,
|
||||||
|
@ -259,19 +277,28 @@ export const onboardUsers = async (ctx: any) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
let bulkCreateReponse = await userSdk.bulkCreate(users, [])
|
let bulkCreateReponse = await userSdk.bulkCreate(users, [])
|
||||||
onboardingResponse = {
|
|
||||||
|
// Apply temporary credentials
|
||||||
|
let createWithCredentials = {
|
||||||
...bulkCreateReponse,
|
...bulkCreateReponse,
|
||||||
|
successful: bulkCreateReponse?.successful.map(user => {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
password: createdPasswords[user.email],
|
||||||
|
}
|
||||||
|
}),
|
||||||
created: true,
|
created: true,
|
||||||
}
|
}
|
||||||
ctx.body = onboardingResponse
|
|
||||||
|
ctx.body = createWithCredentials
|
||||||
} else {
|
} else {
|
||||||
ctx.throw(400, "User onboarding failed")
|
ctx.throw(400, "User onboarding failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const invite = async (ctx: any) => {
|
export const invite = async (ctx: any) => {
|
||||||
const request = ctx.request.body as InviteUserRequest
|
const request = ctx.request.body as InviteUsersRequest
|
||||||
const response = await userSdk.invite([request])
|
const response = await userSdk.invite(request)
|
||||||
|
|
||||||
// explicitly throw for single user invite
|
// explicitly throw for single user invite
|
||||||
if (response.unsuccessful.length) {
|
if (response.unsuccessful.length) {
|
||||||
|
|
|
@ -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) => {
|
const createUserAdminOnly = (ctx: any, next: any) => {
|
||||||
if (!ctx.request.body._id) {
|
if (!ctx.request.body._id) {
|
||||||
return auth.adminOnly(ctx, next)
|
return auth.adminOnly(ctx, next)
|
||||||
|
@ -88,22 +81,34 @@ router
|
||||||
.get("/api/global/roles/:appId")
|
.get("/api/global/roles/:appId")
|
||||||
.post(
|
.post(
|
||||||
"/api/global/users/invite",
|
"/api/global/users/invite",
|
||||||
auth.adminOnly,
|
auth.builderOrAdmin,
|
||||||
buildInviteValidation(),
|
buildInviteValidation(),
|
||||||
controller.invite
|
controller.invite
|
||||||
)
|
)
|
||||||
|
.post(
|
||||||
|
"/api/global/users/onboard",
|
||||||
|
auth.builderOrAdmin,
|
||||||
|
buildInviteMultipleValidation(),
|
||||||
|
controller.onboardUsers
|
||||||
|
)
|
||||||
.post(
|
.post(
|
||||||
"/api/global/users/multi/invite",
|
"/api/global/users/multi/invite",
|
||||||
auth.adminOnly,
|
auth.builderOrAdmin,
|
||||||
buildInviteMultipleValidation(),
|
buildInviteMultipleValidation(),
|
||||||
controller.inviteMultiple
|
controller.inviteMultiple
|
||||||
)
|
)
|
||||||
|
|
||||||
// non-global endpoints
|
// non-global endpoints
|
||||||
|
.get("/api/global/users/invite/:code", controller.checkInvite)
|
||||||
|
.post(
|
||||||
|
"/api/global/users/invite/update/:code",
|
||||||
|
auth.builderOrAdmin,
|
||||||
|
controller.updateInvite
|
||||||
|
)
|
||||||
.get(
|
.get(
|
||||||
"/api/global/users/invite/:code",
|
"/api/global/users/invites",
|
||||||
buildInviteLookupValidation(),
|
auth.builderOrAdmin,
|
||||||
controller.checkInvite
|
controller.getUserInvites
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
"/api/global/users/invite/accept",
|
"/api/global/users/invite/accept",
|
||||||
|
|
|
@ -56,11 +56,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 ({
|
export const paginatedUsers = async ({
|
||||||
page,
|
page,
|
||||||
email,
|
email,
|
||||||
appId,
|
appId,
|
||||||
userIds,
|
|
||||||
}: SearchUsersRequest = {}) => {
|
}: SearchUsersRequest = {}) => {
|
||||||
const db = tenancy.getGlobalDB()
|
const db = tenancy.getGlobalDB()
|
||||||
// get one extra document, to have the next page
|
// get one extra document, to have the next page
|
||||||
|
|
|
@ -130,11 +130,9 @@ export async function checkInviteCode(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
Get all currently available user invitations.
|
Get all currently available user invitations.
|
||||||
@return {Object[]} A
|
@return {Object[]} A list of all objects containing invite metadata
|
||||||
**/
|
**/
|
||||||
export async function getInviteCodes(
|
export async function getInviteCodes(tenantIds?: string[]) {
|
||||||
tenantIds?: string[] //should default to the current tenant of the user session.
|
|
||||||
) {
|
|
||||||
const client = await getClient(redis.utils.Databases.INVITATIONS)
|
const client = await getClient(redis.utils.Databases.INVITATIONS)
|
||||||
const invites: any[] = await client.scan()
|
const invites: any[] = await client.scan()
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue