Merge branch 'develop' of github.com:Budibase/budibase into cheeks-fixes

This commit is contained in:
Andrew Kingston 2023-06-26 17:56:11 +01:00
commit b7603f8bf1
66 changed files with 864 additions and 1461 deletions

View File

@ -126,6 +126,16 @@ http {
proxy_pass http://app-service;
}
location /embed {
rewrite /embed/(.*) /app/$1 break;
proxy_pass http://app-service;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header x-budibase-embed "true";
add_header x-budibase-embed "true";
add_header Content-Security-Policy "frame-ancestors *";
}
location /builder {
proxy_read_timeout 120s;
proxy_connect_timeout 120s;

View File

@ -92,6 +92,16 @@ http {
proxy_pass $apps;
}
location /embed {
rewrite /embed/(.*) /app/$1 break;
proxy_pass $apps;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header x-budibase-embed "true";
add_header x-budibase-embed "true";
add_header Content-Security-Policy "frame-ancestors *";
}
location = / {
proxy_pass $apps;
}

View File

@ -1,5 +1,5 @@
{
"version": "2.7.34-alpha.4",
"version": "2.7.34-alpha.9",
"npmClient": "yarn",
"packages": [
"packages/*"

View File

@ -14,10 +14,15 @@ async function servedBuilder(timezone: string) {
await publishEvent(Event.SERVED_BUILDER, properties)
}
async function servedApp(app: App, timezone: string) {
async function servedApp(
app: App,
timezone: string,
embed?: boolean | undefined
) {
const properties: AppServedEvent = {
appVersion: app.version,
timezone,
embed: embed === true,
}
await publishEvent(Event.SERVED_APP, properties)
}

View File

@ -120,10 +120,13 @@ export const getFrontendStore = () => {
reset: () => {
store.set({ ...INITIAL_FRONTEND_STATE })
websocket?.disconnect()
websocket = null
},
initialise: async pkg => {
const { layouts, screens, application, clientLibPath, hasLock } = pkg
if (!websocket) {
websocket = createBuilderWebsocket(application.appId)
}
await store.actions.components.refreshDefinitions(application.appId)
// Reset store state

View File

@ -333,7 +333,7 @@
: null}>{value.title || (key === "row" ? "Table" : key)}</Label
>
{/if}
{#if value.type === "string" && value.enum && canShowField(key)}
{#if value.type === "string" && value.enum && canShowField(key, value)}
<Select
on:change={e => onChange(e, key)}
value={inputData[key]}

View File

@ -55,7 +55,7 @@
name: "Automations",
description: "",
icon: "Compass",
action: () => $goto("./automate"),
action: () => $goto("./automation"),
},
{
type: "Publish",
@ -127,7 +127,7 @@
type: "Automation",
name: automation.name,
icon: "ShareAndroid",
action: () => $goto(`./automate/${automation._id}`),
action: () => $goto(`./automation/${automation._id}`),
})),
...Constants.Themes.map(theme => ({
type: "Change Builder Theme",

View File

@ -3,34 +3,45 @@
notifications,
Popover,
Layout,
Heading,
Body,
Button,
ActionButton,
Icon,
Link,
Modal,
StatusLight,
} from "@budibase/bbui"
import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import { processStringSync } from "@budibase/string-templates"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import analytics, { Events, EventSource } from "analytics"
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
import { API } from "api"
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"
import { goto } from "@roxi/routify"
export let application
export let loaded
let publishPopover
let publishPopoverAnchor
let unpublishModal
let updateAppModal
let revertModal
let versionModal
$: filteredApps = $apps.filter(
app => app.devId === application && app.status === "published"
)
let appActionPopover
let appActionPopoverOpen = false
let appActionPopoverAnchor
let publishing = false
$: filteredApps = $apps.filter(app => app.devId === application)
$: selectedApp = filteredApps?.length ? filteredApps[0] : null
$: deployments = []
@ -38,7 +49,29 @@
.filter(deployment => deployment.status === "SUCCESS")
.sort((a, b) => a.updatedAt > b.updatedAt)
$: isPublished = selectedApp && latestDeployments?.length > 0
$: isPublished =
selectedApp?.status === "published" && latestDeployments?.length > 0
$: updateAvailable =
$store.upgradableVersion &&
$store.version &&
$store.upgradableVersion !== $store.version
$: canPublish = !publishing && loaded
const initialiseApp = async () => {
const applicationPkg = await API.fetchAppPackage($store.devId)
await store.actions.initialise(applicationPkg)
}
const updateDeploymentString = () => {
return deployments?.length
? processStringSync("Published {{ duration time 'millisecond' }} ago", {
time:
new Date().getTime() - new Date(deployments[0].updatedAt).getTime(),
})
: ""
}
const reviewPendingDeployments = (deployments, newDeployments) => {
if (deployments.length > 0) {
@ -80,11 +113,36 @@
}
}
async function publishApp() {
try {
publishing = true
await API.publishAppChanges($store.appId)
notifications.send("App published", {
type: "success",
icon: "GlobeCheck",
})
await completePublish()
} catch (error) {
console.error(error)
analytics.captureException(error)
notifications.error("Error publishing app")
}
publishing = false
}
const unpublishApp = () => {
publishPopover.hide()
appActionPopover.hide()
unpublishModal.show()
}
const revertApp = () => {
appActionPopover.hide()
revertModal.show()
}
const confirmUnpublishApp = async () => {
if (!application || !isPublished) {
//confirm the app has loaded.
@ -93,7 +151,10 @@
try {
await API.unpublishApp(selectedApp.prodId)
await apps.load()
notifications.success("App unpublished successfully")
notifications.send("App unpublished", {
type: "success",
icon: "GlobeStrike",
})
} catch (err) {
notifications.error("Error unpublishing app")
}
@ -117,83 +178,29 @@
</script>
{#if $store.hasLock}
<div class="action-top-nav">
<div class="action-top-nav" class:has-lock={$store.hasLock}>
<div class="action-buttons">
<div class="version">
<VersionModal />
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#if updateAvailable}
<div class="app-action-button version" on:click={versionModal.show}>
<div class="app-action">
<ActionButton quiet>
<StatusLight notice />
Update
</ActionButton>
</div>
<RevertModal />
{#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">
<div class="app-action-button users">
<div class="app-action" id="builder-app-users-button">
<ActionButton
quiet
icon="UserGroup"
size="M"
on:click={() => {
store.update(state => {
state.builderSidePanel = true
@ -203,11 +210,129 @@
>
Users
</ActionButton>
</div>
</div>
</TourWrap>
<div class="app-action-button preview">
<div class="app-action">
<ActionButton quiet icon="PlayCircle" on:click={previewApp}>
Preview
</ActionButton>
</div>
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="app-action-button publish app-action-popover"
on:click={() => {
if (!appActionPopoverOpen) {
appActionPopover.show()
} else {
appActionPopover.hide()
}
}}
>
<div bind:this={appActionPopoverAnchor}>
<div class="app-action">
<Icon name={isPublished ? "GlobeCheck" : "GlobeStrike"} />
<TourWrap tourStepKey={TOUR_STEP_KEYS.BUILDER_APP_PUBLISH}>
<span class="publish-open" id="builder-app-publish-button">
Publish
<Icon
name={appActionPopoverOpen ? "ChevronUp" : "ChevronDown"}
size="M"
/>
</span>
</TourWrap>
</div>
</div>
<Popover
bind:this={appActionPopover}
align="right"
disabled={!isPublished}
anchor={appActionPopoverAnchor}
offset={35}
on:close={() => {
appActionPopoverOpen = false
}}
on:open={() => {
appActionPopoverOpen = true
}}
>
<div class="app-action-popover-content">
<Layout noPadding gap="M">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Body size="M">
<span
class="app-link"
on:click={() => {
if (isPublished) {
viewApp()
} else {
appActionPopover.hide()
updateAppModal.show()
}
}}
>
{selectedApp ? `${selectedApp?.url}` : ""}
{#if isPublished}
<Icon size="S" name="LinkOut" />
{:else}
<Icon size="S" name="Edit" />
{/if}
</span>
</Body>
<Body size="S">
<span class="publish-popover-status">
{#if isPublished}
<span class="status-text">
{updateDeploymentString(deployments)}
</span>
<span class="unpublish-link">
<Link quiet on:click={unpublishApp}>Unpublish</Link>
</span>
<span class="revert-link">
<Link quiet secondary on:click={revertApp}>Revert</Link>
</span>
{:else}
<span class="status-text unpublished">Not published</span>
{/if}
</span>
</Body>
<div class="action-buttons">
{#if $store.hasLock}
{#if isPublished}
<ActionButton
quiet
icon="Code"
on:click={() => {
$goto("./settings/embed")
appActionPopover.hide()
}}
>
Embed
</ActionButton>
{/if}
<Button
cta
on:click={publishApp}
id={"builder-app-publish-button"}
disabled={!canPublish}
>
Publish
</Button>
{/if}
</div>
</Layout>
</div>
</Popover>
</div>
</div>
</div>
<!-- Modals -->
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
@ -216,45 +341,117 @@
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
{/if}
<div class="buttons">
<Button on:click={previewApp} secondary>Preview</Button>
{#if $store.hasLock}
<DeployModal onOk={completePublish} />
{/if}
<Modal bind:this={updateAppModal} padding={false} width="600px">
<UpdateAppModal
app={selectedApp}
onUpdateComplete={async () => {
await initialiseApp()
}}
/>
</Modal>
<RevertModal bind:this={revertModal} />
<VersionModal hideIcon bind:this={versionModal} />
{:else}
<div class="app-action-button preview-locked">
<div class="app-action">
<ActionButton quiet icon="PlayCircle" on:click={previewApp}>
Preview
</ActionButton>
</div>
</div>
{/if}
<style>
/* .banner-btn {
display: flex;
align-items: center;
gap: var(--spacing-s);
} */
.popover-content {
.app-action-popover-content {
padding: var(--spacing-xl);
width: 360px;
}
.buttons {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-l);
.app-action-popover-content :global(.icon svg.spectrum-Icon) {
height: 0.8em;
}
.action-buttons {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
/* gap: var(--spacing-s); */
}
.version {
margin-right: var(--spacing-s);
height: 100%;
}
.action-top-nav {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
height: 100%;
}
.app-link {
display: flex;
align-items: center;
gap: var(--spacing-s);
cursor: pointer;
}
.app-action-popover-content .status-text {
color: var(--spectrum-global-color-green-500);
border-right: 1px solid var(--spectrum-global-color-gray-500);
padding-right: var(--spacing-m);
}
.app-action-popover-content .status-text.unpublished {
color: var(--spectrum-global-color-gray-600);
border-right: 0px;
padding-right: 0px;
}
.app-action-popover-content .action-buttons {
gap: var(--spacing-m);
}
.app-action-popover-content
.publish-popover-status
.unpublish-link
:global(.spectrum-Link) {
color: var(--spectrum-global-color-red-400);
}
.publish-popover-status {
display: flex;
gap: var(--spacing-m);
}
.app-action-popover .publish-open {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
.app-action-button {
height: 100%;
display: flex;
align-items: center;
padding-right: var(--spacing-m);
}
.app-action-button.publish:hover {
background-color: var(--spectrum-global-color-gray-200);
cursor: pointer;
}
.app-action-button.publish {
border-left: var(--border-light);
padding: 0px var(--spacing-l);
}
.app-action-button.version :global(.spectrum-ActionButton-label) {
display: flex;
gap: var(--spectrum-actionbutton-icon-gap);
}
.app-action-button.preview-locked {
padding-right: 0px;
}
.app-action {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
</style>

View File

@ -0,0 +1,45 @@
<script>
import { Input, notifications } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { store } from "builderStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { apps } from "stores/portal"
import { API } from "api"
export const show = () => {
deletionModal.show()
}
export const hide = () => {
deletionModal.hide()
}
let deletionModal
let deletionConfirmationAppName
const deleteApp = async () => {
try {
await API.deleteApp($store.appId)
apps.load()
notifications.success("App deleted successfully")
$goto("/builder")
} catch (err) {
notifications.error("Error deleting app")
}
}
</script>
<ConfirmDialog
bind:this={deletionModal}
title="Delete app"
okText="Delete"
onOk={deleteApp}
onCancel={() => (deletionConfirmationAppName = null)}
disabled={deletionConfirmationAppName !== $store.name}
>
Are you sure you want to delete <b>{$store.name}</b>?
<br />
Please enter the app name below to confirm.
<br /><br />
<Input bind:value={deletionConfirmationAppName} placeholder={$store.name} />
</ConfirmDialog>

View File

@ -1,15 +1,9 @@
<script>
import {
Input,
Modal,
notifications,
ModalContent,
ActionButton,
} from "@budibase/bbui"
import { Input, Modal, notifications, ModalContent } from "@budibase/bbui"
import { store } from "builderStore"
import { API } from "api"
export let disabled = false
export let onComplete = () => {}
let revertModal
let appName
@ -24,20 +18,20 @@
const applicationPkg = await API.fetchAppPackage(appId)
await store.actions.initialise(applicationPkg)
notifications.info("Changes reverted successfully")
onComplete()
} catch (error) {
notifications.error(`Error reverting changes: ${error}`)
}
}
</script>
<ActionButton
quiet
icon="Revert"
size="M"
tooltip="Revert changes"
on:click={revertModal.show}
{disabled}
/>
export const hide = () => {
revertModal.hide()
}
export const show = () => {
revertModal.show()
}
</script>
<Modal bind:this={revertModal}>
<ModalContent

View File

@ -18,6 +18,7 @@
updateModal.hide()
}
export let onComplete = () => {}
export let hideIcon = false
let updateModal
@ -47,6 +48,7 @@
notifications.success(
`App updated successfully to version ${$store.upgradableVersion}`
)
onComplete()
} catch (err) {
notifications.error(`Error updating app: ${err}`)
}
@ -70,9 +72,7 @@
</script>
{#if !hideIcon && updateAvailable}
<StatusLight hoverable on:click={updateModal.show} notice>
Update available
</StatusLight>
<StatusLight hoverable on:click={updateModal.show} notice>Update</StatusLight>
{/if}
<Modal bind:this={updateModal}>
<ModalContent

View File

@ -11,7 +11,7 @@ export const TOUR_STEP_KEYS = {
BUILDER_DATA_SECTION: "builder-data-section",
BUILDER_DESIGN_SECTION: "builder-design-section",
BUILDER_USER_MANAGEMENT: "builder-user-management",
BUILDER_AUTOMATE_SECTION: "builder-automate-section",
BUILDER_AUTOMATION_SECTION: "builder-automation-section",
FEATURE_USER_MANAGEMENT: "feature-user-management",
}
@ -34,7 +34,7 @@ const getTours = () => {
title: "Data",
route: "/builder/app/:application/data",
layout: OnboardingData,
query: ".topcenternav .spectrum-Tabs-item#builder-data-tab",
query: ".topleftnav .spectrum-Tabs-item#builder-data-tab",
onLoad: async () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_DATA_SECTION)
},
@ -45,20 +45,20 @@ const getTours = () => {
title: "Design",
route: "/builder/app/:application/design",
layout: OnboardingDesign,
query: ".topcenternav .spectrum-Tabs-item#builder-design-tab",
query: ".topleftnav .spectrum-Tabs-item#builder-design-tab",
onLoad: () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_DESIGN_SECTION)
},
align: "left",
},
{
id: TOUR_STEP_KEYS.BUILDER_AUTOMATE_SECTION,
id: TOUR_STEP_KEYS.BUILDER_AUTOMATION_SECTION,
title: "Automations",
route: "/builder/app/:application/automate",
query: ".topcenternav .spectrum-Tabs-item#builder-automate-tab",
route: "/builder/app/:application/automation",
query: ".topleftnav .spectrum-Tabs-item#builder-automation-tab",
body: "Once you have your app screens made, you can set up automations to fit in with your current workflow",
onLoad: () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_AUTOMATE_SECTION)
tourEvent(TOUR_STEP_KEYS.BUILDER_AUTOMATION_SECTION)
},
align: "left",
},

View File

@ -4,18 +4,27 @@
export let active = false
</script>
{#if url}
<a on:click href={url} class:active>
{text || ""}
</a>
{:else}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span on:click class:active>
{text || ""}
</span>
{/if}
<style>
a {
a,
span {
padding: var(--spacing-s) var(--spacing-m);
color: var(--spectrum-global-color-gray-900);
border-radius: 4px;
transition: background 130ms ease-out;
}
.active,
span:hover,
a:hover {
background-color: var(--spectrum-global-color-gray-200);
cursor: pointer;

View File

@ -22,7 +22,7 @@
}
const goToOverview = () => {
$goto(`../overview/${app.devId}`)
$goto(`../../app/${app.devId}/settings`)
}
</script>

View File

@ -14,6 +14,7 @@
import EditableIcon from "../common/EditableIcon.svelte"
export let app
export let onUpdateComplete
const values = writable({
name: app.name,
@ -54,6 +55,9 @@
color: $values.iconColor,
},
})
if (typeof onUpdateComplete == "function") {
onUpdateComplete()
}
} catch (error) {
console.error(error)
notifications.error("Error updating app")

View File

@ -4,8 +4,6 @@
import { auth } from "stores/portal"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
import {
ActionMenu,
MenuItem,
Icon,
Tabs,
Tab,
@ -142,56 +140,17 @@
{/if}
<div class="root" class:blur={$store.showPreview}>
<div class="top-nav">
<div class="top-nav" class:has-lock={$store.hasLock}>
{#if $store.initialised}
<div class="topleftnav">
<ActionMenu>
<div slot="control">
<Icon size="M" hoverable name="ShowMenu" />
</div>
<MenuItem on:click={() => $goto("../../portal/apps")}>
Exit to portal
</MenuItem>
<MenuItem
on:click={() => $goto(`../../portal/overview/${application}`)}
>
Overview
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/access`)}
>
Access
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/automation-history`)}
>
Automation history
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/backups`)}
>
Backups
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/name-and-url`)}
>
Name and URL
</MenuItem>
<MenuItem
on:click={() =>
$goto(`../../portal/overview/${application}/version`)}
>
Version
</MenuItem>
</ActionMenu>
<Heading size="XS">{$store.name}</Heading>
</div>
<div class="topcenternav">
<span class="back-to-apps">
<Icon
size="S"
hoverable
name="BackAndroid"
on:click={() => $goto("../../portal/apps")}
/>
</span>
{#if $store.hasLock}
<Tabs {selected} size="M">
{#each $layout.children as { path, title }}
@ -209,13 +168,23 @@
{:else}
<div class="secondary-editor">
<Icon name="LockClosed" />
<div
class="secondary-editor-body"
title="Another user is currently editing your screens and automations"
>
Another user is currently editing your screens and automations
</div>
</div>
{/if}
</div>
<div class="topcenternav">
<Heading size="XS">{$store.name}</Heading>
</div>
<div class="toprightnav">
<span class:nav-lock={!$store.hasLock}>
<UserAvatars users={$userStore} />
<AppActions {application} />
</span>
<AppActions {application} {loaded} />
</div>
{/if}
</div>
@ -241,6 +210,13 @@
</Modal>
<style>
.back-to-apps {
display: contents;
}
.back-to-apps :global(.icon) {
margin-left: 12px;
margin-right: 12px;
}
.loading {
min-height: 100%;
height: 100%;
@ -272,27 +248,34 @@
z-index: 2;
}
.topleftnav {
.top-nav.has-lock {
padding-right: 0px;
}
.topcenternav {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xl);
}
.topleftnav :global(.spectrum-Heading) {
.topcenternav :global(.spectrum-Heading) {
flex: 1 1 auto;
width: 0;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
padding: 0px var(--spacing-m);
}
.topcenternav {
.topleftnav {
display: flex;
position: relative;
margin-bottom: -2px;
overflow: hidden;
}
.topcenternav :global(.spectrum-Tabs-itemLabel) {
.topleftnav :global(.spectrum-Tabs-itemLabel) {
font-weight: 600;
}
@ -301,7 +284,10 @@
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-l);
}
.toprightnav :global(.avatars) {
margin-right: var(--spacing-l);
}
.secondary-editor {
@ -309,6 +295,16 @@
display: flex;
flex-direction: row;
gap: 8px;
min-width: 0;
overflow: hidden;
margin-left: var(--spacing-xl);
}
.secondary-editor-body {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0px;
}
.body {

View File

@ -0,0 +1,78 @@
<script>
import { Content, SideNav, SideNavItem } from "components/portal/page"
import { Page, Layout } from "@budibase/bbui"
import { url, isActive } from "@roxi/routify"
import DeleteModal from "components/deploy/DeleteModal.svelte"
let deleteModal
</script>
<!-- routify:options index=4 -->
<div class="settings">
<Page>
<Layout noPadding gap="L">
<Content showMobileNav>
<SideNav slot="side-nav">
<SideNavItem
text="Automation History"
url={$url("./automation-history")}
active={$isActive("./automation-history")}
/>
<SideNavItem
text="Backups"
url={$url("./backups")}
active={$isActive("./backups")}
/>
<SideNavItem
text="Embed"
url={$url("./embed")}
active={$isActive("./embed")}
/>
<SideNavItem
text="Export"
url={$url("./export")}
active={$isActive("./export")}
/>
<SideNavItem
text="Name and URL"
url={$url("./name-and-url")}
active={$isActive("./name-and-url")}
/>
<SideNavItem
text="Version"
url={$url("./version")}
active={$isActive("./version")}
/>
<div class="delete-action">
<SideNavItem
text="Delete app"
on:click={() => {
deleteModal.show()
}}
/>
</div>
</SideNav>
<slot />
</Content>
</Layout>
</Page>
</div>
<DeleteModal bind:this={deleteModal} />
<style>
.delete-action :global(span) {
color: var(--spectrum-global-color-red-400);
}
.delete-action {
display: contents;
}
.settings {
flex: 1 1 auto;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
height: 0;
}
</style>

View File

@ -47,7 +47,7 @@
<Button
secondary
on:click={() => {
$goto(`../../../../app/${appId}/automate/${history.automationId}`)
$goto(`/builder/app/${appId}/automation/${history.automationId}`)
}}
>
Edit automation

View File

@ -12,11 +12,11 @@
import DateTimeRenderer from "components/common/renderers/DateTimeRenderer.svelte"
import StatusRenderer from "./_components/StatusRenderer.svelte"
import HistoryDetailsPanel from "./_components/HistoryDetailsPanel.svelte"
import { automationStore } from "builderStore"
import { automationStore, store } from "builderStore"
import { createPaginationStore } from "helpers/pagination"
import { getContext, onDestroy, onMount } from "svelte"
import dayjs from "dayjs"
import { auth, licensing, admin, overview } from "stores/portal"
import { auth, licensing, admin } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
import Portal from "svelte-portal"
@ -35,7 +35,6 @@
let loaded = false
$: licensePlan = $auth.user?.license?.plan
$: app = $overview.selectedApp
$: page = $pageInfo.page
$: fetchLogs(automationId, status, page, timeRange)
@ -191,7 +190,8 @@
/>
</div>
</div>
{#if (licensePlan?.type !== Constants.PlanType.ENTERPRISE && $auth.user.accountPortalAccess) || !$admin.cloud}
{#if (!$licensing.isEnterprisePlan && $auth.user.accountPortalAccess) || !$admin.cloud}
<Button secondary on:click={$licensing.goToUpgradePage()}>
Get more history
</Button>
@ -227,7 +227,7 @@
{#if selectedHistory}
<Portal target="#side-panel">
<HistoryDetailsPanel
appId={app.devId}
appId={$store.appId}
bind:history={selectedHistory}
close={sidePanel.close}
/>

View File

@ -13,7 +13,8 @@
Tag,
Table,
} from "@budibase/bbui"
import { backups, licensing, auth, admin, overview } from "stores/portal"
import { backups, licensing, auth, admin } from "stores/portal"
import { store } from "builderStore"
import { createPaginationStore } from "helpers/pagination"
import TimeAgoRenderer from "./_components/TimeAgoRenderer.svelte"
import AppSizeRenderer from "./_components/AppSizeRenderer.svelte"
@ -50,7 +51,6 @@
},
]
$: app = $overview.selectedApp
$: page = $pageInfo.page
$: fetchBackups(filterOpt, page, startDate, endDate)
@ -101,7 +101,7 @@
async function fetchBackups(filters, page, startDate, endDate) {
const response = await backups.searchBackups({
appId: app.instance._id,
appId: $store.appId,
...filters,
page,
startDate,
@ -117,7 +117,7 @@
try {
loading = true
let response = await backups.createManualBackup({
appId: app.instance._id,
appId: $store.appId,
})
await fetchBackups(filterOpt, page)
notifications.success(response.message)
@ -143,20 +143,20 @@
async function handleButtonClick({ detail }) {
if (detail.type === "backupDelete") {
await backups.deleteBackup({
appId: app.instance._id,
appId: $store.appId,
backupId: detail.backupId,
})
await fetchBackups(filterOpt, page)
} else if (detail.type === "backupRestore") {
await backups.restoreBackup({
appId: app.instance._id,
appId: $store.appId,
backupId: detail.backupId,
name: detail.restoreBackupName,
})
await fetchBackups(filterOpt, page)
} else if (detail.type === "backupUpdate") {
await backups.updateBackup({
appId: app.instance._id,
appId: $store.appId,
backupId: detail.backupId,
name: detail.name,
})
@ -333,10 +333,6 @@
gap: var(--spacing-m);
}
.table {
overflow-x: scroll;
}
.center {
text-align: center;
display: contents;

View File

@ -0,0 +1,75 @@
<script>
import {
Layout,
Body,
Heading,
Divider,
Button,
Helpers,
Icon,
notifications,
} from "@budibase/bbui"
import { AppStatus } from "constants"
import { apps } from "stores/portal"
import { store } from "builderStore"
$: filteredApps = $apps.filter(app => app.devId == $store.appId)
$: app = filteredApps.length ? filteredApps[0] : {}
$: appUrl = `${window.origin}/embed${app?.url}`
$: appDeployed = app?.status === AppStatus.DEPLOYED
$: embed = `<iframe width="800" height="600" frameborder="0" allow="clipboard-write;camera" src="${appUrl}"></iframe>`
</script>
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading>Embed</Heading>
<Body>Embed your app into your other tools of choice</Body>
</Layout>
<Divider />
<div class="embed-body">
<div class="embed-code">{embed}</div>
{#if appDeployed}
<div>
<Button
cta
disabled={!appDeployed}
on:click={async () => {
await Helpers.copyToClipboard(embed)
notifications.success("Copied")
}}
>
Copy Code
</Button>
</div>
{:else}
<div class="embed-info">
<Icon size="S" name="Info" /> Embeds will only work with a published app
</div>
{/if}
</div>
</Layout>
<style>
.embed-info {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
.embed-body {
display: flex;
flex-direction: column;
gap: var(--spectrum-alias-grid-gutter-small);
}
.embed-code {
display: flex;
align-items: center;
/* justify-content: center; */
background-color: var(
--spectrum-textfield-m-background-color,
var(--spectrum-global-color-gray-50)
);
border-radius: var(--border-radius-s);
padding: var(--spacing-xl);
}
</style>

View File

@ -0,0 +1,57 @@
<script>
import {
Layout,
Body,
Heading,
Divider,
ActionButton,
Modal,
} from "@budibase/bbui"
import { AppStatus } from "constants"
import { apps } from "stores/portal"
import { store } from "builderStore"
import ExportAppModal from "components/start/ExportAppModal.svelte"
$: filteredApps = $apps.filter(app => app.devId == $store.appId)
$: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED
let exportModal
let exportPublishedVersion = false
const exportApp = opts => {
exportPublishedVersion = !!opts?.published
exportModal.show()
}
</script>
<Modal bind:this={exportModal} padding={false}>
<ExportAppModal {app} published={exportPublishedVersion} />
</Modal>
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading>Export your app</Heading>
<Body>Export your latest edited or published app</Body>
</Layout>
<Divider />
<div class="export-body">
<ActionButton secondary on:click={() => exportApp({ published: false })}>
Export latest edited app
</ActionButton>
<ActionButton
secondary
disabled={!appDeployed}
on:click={() => exportApp({ published: true })}
>
Export latest published app
</ActionButton>
</div>
</Layout>
<style>
.export-body {
display: flex;
gap: var(--spacing-l);
}
</style>

View File

@ -1,4 +1,5 @@
<script>
import { redirect } from "@roxi/routify"
$redirect("../")
$redirect("../settings/automation-history")
</script>

View File

@ -10,14 +10,22 @@
Icon,
} from "@budibase/bbui"
import { AppStatus } from "constants"
import { overview } from "stores/portal"
import { store } from "builderStore"
import { apps } from "stores/portal"
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import { API } from "api"
let updatingModal
$: app = $overview.selectedApp
$: filteredApps = $apps.filter(app => app.devId == $store.appId)
$: app = filteredApps.length ? filteredApps[0] : {}
$: appUrl = `${window.origin}/app${app?.url}`
$: appDeployed = app?.status === AppStatus.DEPLOYED
const initialiseApp = async () => {
const applicationPkg = await API.fetchAppPackage(app.devId)
await store.actions.initialise(applicationPkg)
}
</script>
<Layout noPadding>
@ -66,7 +74,12 @@
</Layout>
<Modal bind:this={updatingModal} padding={false} width="600px">
<UpdateAppModal app={$overview.selectedApp} />
<UpdateAppModal
{app}
onUpdateComplete={async () => {
await initialiseApp()
}}
/>
</Modal>
<style>

View File

@ -100,7 +100,9 @@
const params = new URLSearchParams({
open: "error",
})
$goto(`../overview/${appId}/automation-history?${params.toString()}`)
$goto(
`/builder/app/${appId}/settings/automation-history?${params.toString()}`
)
}
const errorCount = errors => {

View File

@ -1,252 +0,0 @@
<script>
import { url, isActive, goto } from "@roxi/routify"
import {
Page,
Layout,
Button,
Icon,
ActionMenu,
MenuItem,
Helpers,
Input,
Modal,
notifications,
} from "@budibase/bbui"
import {
Content,
SideNav,
SideNavItem,
Breadcrumbs,
Breadcrumb,
Header,
} from "components/portal/page"
import { apps, overview } from "stores/portal"
import { AppStatus } from "constants"
import analytics, { Events, EventSource } from "analytics"
import { store } from "builderStore"
import EditableIcon from "components/common/EditableIcon.svelte"
import { API } from "api"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import ExportAppModal from "components/start/ExportAppModal.svelte"
import { syncURLToState } from "helpers/urlStateSync"
import * as routify from "@roxi/routify"
import { onDestroy } from "svelte"
// Keep URL and state in sync for selected app ID
const stopSyncing = syncURLToState({
urlParam: "appId",
stateKey: "selectedAppId",
validate: id => $apps.some(app => app.devId === id),
fallbackUrl: "../../",
store: overview,
routify,
})
let exportModal
let deletionModal
let exportPublishedVersion = false
let deletionConfirmationAppName
let loaded = false
$: app = $overview.selectedApp
$: appId = $overview.selectedAppId
$: initialiseApp(appId)
$: isPublished = app?.status === AppStatus.DEPLOYED
const initialiseApp = async appId => {
loaded = false
try {
const pkg = await API.fetchAppPackage(appId)
await store.actions.initialise(pkg)
await API.syncApp(appId)
loaded = true
} catch (error) {
notifications.error("Error initialising app overview")
$goto("../../")
}
}
const viewApp = () => {
if (isPublished) {
analytics.captureEvent(Events.APP_VIEW_PUBLISHED, {
appId: $store.appId,
eventSource: EventSource.PORTAL,
})
window.open(`/app${app?.url}`, "_blank")
}
}
const editApp = () => {
$goto(`../../../app/${app.devId}`)
}
const exportApp = opts => {
exportPublishedVersion = !!opts?.published
exportModal.show()
}
const copyAppId = async () => {
await Helpers.copyToClipboard(app.prodId)
notifications.success("App ID copied to clipboard")
}
const deleteApp = async () => {
try {
await API.deleteApp(app?.devId)
apps.load()
notifications.success("App deleted successfully")
$goto("../../")
} catch (err) {
notifications.error("Error deleting app")
}
}
onDestroy(() => {
stopSyncing()
store.actions.reset()
})
</script>
{#key appId}
<Page>
<Layout noPadding gap="L">
<Breadcrumbs>
<Breadcrumb url={$url("../")} text="Apps" />
<Breadcrumb text={app?.name} />
</Breadcrumbs>
<Header title={app?.name} wrap={false}>
<div slot="icon">
<EditableIcon
{app}
autoSave
size="XL"
name={app?.icon?.name || "Apps"}
color={app?.icon?.color}
/>
</div>
<div slot="buttons">
<span class="desktop">
<Button
size="M"
quiet
secondary
disabled={!isPublished}
on:click={viewApp}
>
View
</Button>
</span>
<span class="desktop">
<Button size="M" cta on:click={editApp}>Edit</Button>
</span>
<ActionMenu align="right">
<span slot="control" class="app-overview-actions-icon">
<Icon hoverable name="More" />
</span>
<span class="mobile">
<MenuItem icon="Globe" disabled={!isPublished} on:click={viewApp}>
View
</MenuItem>
</span>
<span class="mobile">
<MenuItem icon="Edit" on:click={editApp}>Edit</MenuItem>
</span>
<MenuItem
on:click={() => exportApp({ published: false })}
icon="DownloadFromCloud"
>
Export latest
</MenuItem>
{#if isPublished}
<MenuItem
on:click={() => exportApp({ published: true })}
icon="DownloadFromCloudOutline"
>
Export published
</MenuItem>
<MenuItem on:click={copyAppId} icon="Copy">Copy app ID</MenuItem>
{/if}
{#if !isPublished}
<MenuItem on:click={deletionModal.show} icon="Delete">
Delete
</MenuItem>
{/if}
</ActionMenu>
</div>
</Header>
<Content showMobileNav>
<SideNav slot="side-nav">
<SideNavItem
text="Overview"
url={$url("./overview")}
active={$isActive("./overview")}
/>
<SideNavItem
text="Access"
url={$url("./access")}
active={$isActive("./access")}
/>
<SideNavItem
text="Automation History"
url={$url("./automation-history")}
active={$isActive("./automation-history")}
/>
<SideNavItem
text="Backups"
url={$url("./backups")}
active={$isActive("./backups")}
/>
<SideNavItem
text="Name and URL"
url={$url("./name-and-url")}
active={$isActive("./name-and-url")}
/>
<SideNavItem
text="Version"
url={$url("./version")}
active={$isActive("./version")}
/>
</SideNav>
{#if loaded}
<slot />
{/if}
</Content>
</Layout>
</Page>
<Modal bind:this={exportModal} padding={false}>
<ExportAppModal {app} published={exportPublishedVersion} />
</Modal>
<ConfirmDialog
bind:this={deletionModal}
title="Delete app"
okText="Delete"
onOk={deleteApp}
onCancel={() => (deletionConfirmationAppName = null)}
disabled={deletionConfirmationAppName !== app?.name}
>
Are you sure you want to delete <b>{app?.name}</b>?
<br />
Please enter the app name below to confirm.
<br /><br />
<Input bind:value={deletionConfirmationAppName} placeholder={app?.name} />
</ConfirmDialog>
{/key}
<style>
.desktop {
display: contents;
}
.mobile {
display: none;
}
@media (max-width: 640px) {
.desktop {
display: none;
}
.mobile {
display: contents;
}
}
</style>

View File

@ -1,211 +0,0 @@
<script>
import {
ModalContent,
PickerDropdown,
ActionButton,
Layout,
Icon,
} from "@budibase/bbui"
import { roles } from "stores/backend"
import { groups, users, licensing, apps } from "stores/portal"
import { Constants, RoleUtils, fetchData } from "@budibase/frontend-core"
import { API } from "api"
import { createEventDispatcher } from "svelte"
export let app
export let appUsers = []
export let showUsers = false
export let showGroups = false
const dispatch = createEventDispatcher()
const usersFetch = fetchData({
API,
datasource: {
type: "user",
},
options: {
query: {
email: "",
},
},
})
let search = ""
let data = [{ id: "", role: "" }]
$: usersFetch.update({
query: {
email: search,
},
})
$: fixedAppId = apps.getProdAppID(app.devId)
$: availableUsers = getAvailableUsers($usersFetch.rows, appUsers, data)
$: availableGroups = getAvailableGroups($groups, app.appId, search, data)
$: valid = data?.length && !data?.some(x => !x.id?.length || !x.role?.length)
$: optionSections = {
...(showGroups &&
$licensing.groupsEnabled &&
availableGroups.length && {
["User groups"]: {
data: availableGroups,
getLabel: group => group.name,
getValue: group => group._id,
getIcon: group => group.icon,
getColour: group => group.color,
},
}),
...(showUsers && {
users: {
data: availableUsers,
getLabel: user => user.email,
getValue: user => user._id,
getIcon: user => user.icon,
getColour: user => user.color,
},
}),
}
const addData = async appData => {
const gr_prefix = "gr"
const us_prefix = "us"
for (let data of appData) {
// Assign group
if (data.id.startsWith(gr_prefix)) {
const group = $groups.find(group => {
return group._id === data.id
})
if (!group) {
continue
}
await groups.actions.addApp(group._id, fixedAppId, data.role)
}
// Assign user
else if (data.id.startsWith(us_prefix)) {
const user = await users.get(data.id)
await users.save({
...user,
roles: {
...user.roles,
[fixedAppId]: data.role,
},
})
}
}
// Refresh data when completed
await usersFetch.refresh()
dispatch("update")
}
const getAvailableUsers = (allUsers, appUsers, newUsers) => {
return (allUsers || []).filter(user => {
// Filter out admin users
if (user?.admin?.global || user?.builder?.global) {
return false
}
// Filter out assigned users
if (appUsers.find(x => x._id === user._id)) {
return false
}
// Filter out new users which are going to be assigned
return !newUsers.find(x => x.id === user._id)
})
}
const getAvailableGroups = (allGroups, appId, search, newGroups) => {
search = search?.toLowerCase()
return (allGroups || []).filter(group => {
// Filter out assigned groups
const appIds = groups.actions.getGroupAppIds(group)
if (appIds.includes(apps.getProdAppID(appId))) {
return false
}
// Filter out new groups which are going to be assigned
if (newGroups.find(x => x.id === group._id)) {
return false
}
// Match search string
return !search || group.name.toLowerCase().includes(search)
})
}
function addNewInput() {
data = [...data, { id: "", role: "" }]
}
const removeItem = index => {
data = data.filter((x, idx) => idx !== index)
}
</script>
<ModalContent
size="M"
title="Assign access to your app"
confirmText="Done"
cancelText="Cancel"
onConfirm={() => addData(data)}
showCloseIcon={false}
disabled={!valid}
>
{#if data.length}
<Layout noPadding gap="XS">
{#each data as input, index}
<div class="item">
<div class="picker">
<PickerDropdown
autocomplete
showClearIcon={false}
primaryOptions={optionSections}
secondaryOptions={$roles.filter(
x => x._id !== Constants.Roles.PUBLIC
)}
secondaryPlaceholder="Access"
bind:primaryValue={input.id}
bind:secondaryValue={input.role}
bind:searchTerm={search}
getPrimaryOptionLabel={group => group.name}
getPrimaryOptionValue={group => group.name}
getPrimaryOptionIcon={group => group.icon}
getPrimaryOptionColour={group => group.colour}
getSecondaryOptionLabel={role => role.name}
getSecondaryOptionValue={role => role._id}
getSecondaryOptionColour={role =>
RoleUtils.getRoleColour(role._id)}
/>
</div>
<div class="icon">
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeItem(index)}
/>
</div>
</div>
{/each}
</Layout>
{/if}
<div>
<ActionButton on:click={addNewInput} icon="Add">Add more</ActionButton>
</div>
</ModalContent>
<style>
.item {
position: relative;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.picker {
width: calc(100% - 30px);
}
.icon {
width: 20px;
}
</style>

View File

@ -1,26 +0,0 @@
<script>
import RoleSelect from "components/common/RoleSelect.svelte"
import { getContext } from "svelte"
const rolesContext = getContext("roles")
export let value
export let row
</script>
<div>
<RoleSelect
{value}
quiet
allowRemove
allowPublic={false}
on:change={e => rolesContext.updateRole(e.detail, row._id)}
on:remove={() => rolesContext.removeRole(row._id)}
/>
</div>
<style>
div {
width: 100%;
}
</style>

View File

@ -1,271 +0,0 @@
<script>
import {
Layout,
Heading,
Body,
Button,
Modal,
notifications,
Pagination,
Divider,
Table,
} from "@budibase/bbui"
import { onMount, setContext } from "svelte"
import { users, groups, apps, licensing, overview } from "stores/portal"
import AssignmentModal from "./_components/AssignmentModal.svelte"
import { roles } from "stores/backend"
import { API } from "api"
import { fetchData } from "@budibase/frontend-core"
import EditableRoleRenderer from "./_components/EditableRoleRenderer.svelte"
const userSchema = {
email: {
type: "string",
width: "1fr",
},
role: {
displayName: "Access",
width: "150px",
borderLeft: true,
},
}
const groupSchema = {
name: {
type: "string",
width: "1fr",
},
role: {
displayName: "Access",
width: "150px",
borderLeft: true,
},
}
const customRenderers = [
{
column: "role",
component: EditableRoleRenderer,
},
]
let assignmentModal
let appGroups
let appUsers
let showAddUsers = false
let showAddGroups = false
$: app = $overview.selectedApp
$: devAppId = app.devId
$: prodAppId = apps.getProdAppID(app.devId)
$: usersFetch = fetchData({
API,
datasource: {
type: "user",
},
options: {
query: {
appId: apps.getProdAppID(devAppId),
},
},
})
$: appUsers = getAppUsers($usersFetch.rows, prodAppId)
$: appGroups = getAppGroups($groups, prodAppId)
const getAppUsers = (users, appId) => {
return users.map(user => ({
...user,
role: user.roles[Object.keys(user.roles).find(x => x === appId)],
}))
}
const getAppGroups = (allGroups, appId) => {
return allGroups
.filter(group => {
if (!group.roles) {
return false
}
return groups.actions.getGroupAppIds(group).includes(appId)
})
.map(group => ({
...group,
role: group.roles[
groups.actions.getGroupAppIds(group).find(x => x === appId)
],
}))
}
const updateRole = async (role, id) => {
// Check if this is a user or a group
if ($usersFetch.rows.some(user => user._id === id)) {
await updateUserRole(role, id)
} else {
await updateGroupRole(role, id)
}
}
const removeRole = async id => {
// Check if this is a user or a group
if ($usersFetch.rows.some(user => user._id === id)) {
await removeUserRole(id)
} else {
await removeGroupRole(id)
}
}
const updateUserRole = async (role, userId) => {
const user = $usersFetch.rows.find(user => user._id === userId)
if (!user) {
return
}
user.roles[prodAppId] = role
await users.save(user)
await usersFetch.refresh()
}
const removeUserRole = async userId => {
const user = $usersFetch.rows.find(user => user._id === userId)
if (!user) {
return
}
const filteredRoles = { ...user.roles }
delete filteredRoles[prodAppId]
await users.save({
...user,
roles: {
...filteredRoles,
},
})
await usersFetch.refresh()
}
const updateGroupRole = async (role, groupId) => {
const group = $groups.find(group => group._id === groupId)
if (!group) {
return
}
await groups.actions.addApp(group._id, prodAppId, role)
await usersFetch.refresh()
}
const removeGroupRole = async groupId => {
const group = $groups.find(group => group._id === groupId)
if (!group) {
return
}
await groups.actions.removeApp(group._id, prodAppId)
await usersFetch.refresh()
}
const showUsersModal = () => {
showAddUsers = true
showAddGroups = false
assignmentModal.show()
}
const showGroupsModal = () => {
showAddUsers = false
showAddGroups = true
assignmentModal.show()
}
setContext("roles", {
updateRole,
removeRole,
})
onMount(async () => {
try {
await roles.fetch()
} catch (error) {
notifications.error(error)
}
})
</script>
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading>Access</Heading>
<Body>Assign users to your app and set their access</Body>
</Layout>
<Divider />
<Layout noPadding gap="L">
{#if $usersFetch.loaded}
<Layout noPadding gap="S">
<div class="title">
<Heading size="S">Users</Heading>
<Button cta on:click={showUsersModal}>Assign user</Button>
</div>
<Table
customPlaceholder
data={appUsers}
schema={userSchema}
allowEditRows={false}
{customRenderers}
>
<div class="placeholder" slot="placeholder">
<Heading size="S">You have no users assigned yet</Heading>
</div>
</Table>
<div class="pagination">
<Pagination
page={$usersFetch.pageNumber + 1}
hasPrevPage={$usersFetch.hasPrevPage}
hasNextPage={$usersFetch.hasNextPage}
goToPrevPage={$usersFetch.loading ? null : usersFetch.prevPage}
goToNextPage={$usersFetch.loading ? null : usersFetch.nextPage}
/>
</div>
</Layout>
{/if}
{#if $usersFetch.loaded && $licensing.groupsEnabled}
<Layout noPadding gap="S">
<div class="title">
<Heading size="S">Groups</Heading>
<Button cta on:click={showGroupsModal}>Assign group</Button>
</div>
<Table
customPlaceholder
data={appGroups}
schema={groupSchema}
allowEditRows={false}
{customRenderers}
>
<div class="placeholder" slot="placeholder">
<Heading size="S">You have no groups assigned yet</Heading>
</div>
</Table>
</Layout>
{/if}
</Layout>
</Layout>
<Modal bind:this={assignmentModal}>
<AssignmentModal
{app}
{appUsers}
on:update={usersFetch.refresh}
showGroups={showAddGroups}
showUsers={showAddUsers}
/>
</Modal>
<style>
.title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-end;
}
.placeholder {
flex: 1 1 auto;
display: grid;
place-items: center;
text-align: center;
}
.pagination {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: calc(-1 * var(--spacing-s));
}
</style>

View File

@ -1,4 +0,0 @@
<script>
import { redirect } from "@roxi/routify"
$redirect("./overview")
</script>

View File

@ -1,368 +0,0 @@
<script>
import { onMount } from "svelte"
import DashCard from "components/common/DashCard.svelte"
import { AppStatus } from "constants"
import { goto } from "@roxi/routify"
import {
Icon,
Heading,
Link,
Layout,
Body,
notifications,
} from "@budibase/bbui"
import { store } from "builderStore"
import { processStringSync } from "@budibase/string-templates"
import { users, auth, apps, groups, overview } from "stores/portal"
import { fetchData, UserAvatar } from "@budibase/frontend-core"
import { API } from "api"
import GroupIcon from "../../users/groups/_components/GroupIcon.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
let appEditor
let unpublishModal
let deployments
$: app = $overview.selectedApp
$: devAppId = app.devId
$: prodAppId = apps.getProdAppID(devAppId)
$: appUsersFetch = fetchData({
API,
datasource: {
type: "user",
},
options: {
query: {
appId: apps.getProdAppID(devAppId),
},
},
})
$: updateAvailable = $store.upgradableVersion !== $store.version
$: isPublished = app?.status === AppStatus.DEPLOYED
$: appEditorId = !app?.updatedBy ? $auth.user._id : app?.updatedBy
$: appEditorText = appEditor?.firstName || appEditor?.email
$: fetchAppEditor(appEditorId)
$: appUsers = $appUsersFetch.rows || []
$: appGroups = $groups.filter(group => {
if (!group.roles) {
return false
}
return groups.actions.getGroupAppIds(group).includes(prodAppId)
})
const updateDeploymentString = () => {
return deployments?.length
? processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(deployments[0].updatedAt).getTime(),
}
)
: ""
}
// App is updating in the layout asynchronously
$: if ($store.appId?.length) {
fetchDeployments().then(resp => {
deployments = resp
})
}
$: deploymentString = updateDeploymentString(deployments)
async function fetchAppEditor(editorId) {
appEditor = await users.get(editorId)
}
const confirmUnpublishApp = async () => {
try {
await API.unpublishApp(app.prodId)
await apps.load()
notifications.success("App unpublished successfully")
} catch (err) {
notifications.error("Error unpublishing app")
}
}
const reviewPendingDeployments = (deployments, newDeployments) => {
if (deployments?.length > 0) {
const pending = checkIncomingDeploymentStatus(deployments, newDeployments)
if (pending.length) {
notifications.warning(
"Deployment has been queued and will be processed shortly"
)
}
}
}
async function fetchDeployments() {
try {
const newDeployments = await API.getAppDeployments()
reviewPendingDeployments(deployments, newDeployments)
return newDeployments
} catch (err) {
console.log(err)
notifications.error("Error fetching deployment history")
}
}
onMount(async () => {
deployments = await fetchDeployments()
})
</script>
<div class="overview-tab">
<Layout noPadding gap="XL">
<div class="top">
<DashCard title={"App Status"}>
<div class="status-content">
<div class="status-display">
{#if isPublished}
<Icon name="GlobeCheck" size="XL" disabled={false} />
<span>Published</span>
{:else}
<Icon name="GlobeStrike" size="XL" disabled={true} />
<span class="disabled">Unpublished</span>
{/if}
</div>
<div class="status-text">
{#if isPublished}
{deploymentString}
- <Link on:click={unpublishModal.show}>Unpublish</Link>
{/if}
{#if !deployments?.length}
-
{/if}
</div>
</div>
</DashCard>
{#if appEditor}
<DashCard title={"Last Edited"}>
<div class="last-edited-content">
<div class="updated-by">
{#if appEditor}
<UserAvatar user={appEditor} showTooltip={false} />
<div class="editor-name">
{appEditor._id === $auth.user._id ? "You" : appEditorText}
</div>
{/if}
</div>
<div class="last-edit-text">
{#if app}
{processStringSync(
"Last edited {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() - new Date(app?.updatedAt).getTime(),
}
)}
{/if}
</div>
</div>
</DashCard>
{/if}
<DashCard
title={"Version"}
showIcon={true}
action={() => {
$goto("./version")
}}
>
<div class="version-content">
<Heading size="XS">{$store.version}</Heading>
{#if updateAvailable}
<div class="version-status">
New version <strong>{$store.upgradableVersion}</strong> is
available -
<Link
on:click={() => {
$goto("./version")
}}
>
Update
</Link>
</div>
{:else}
<div class="version-status">You're running the latest!</div>
{/if}
</div>
</DashCard>
{#if $appUsersFetch.loaded}
<DashCard
title={"Access"}
showIcon={true}
action={() => {
$goto("./access")
}}
>
{#if appUsers.length || appGroups.length}
<Layout noPadding gap="S">
<div class="access-tab-content">
{#if appUsers.length}
<div class="users">
<div class="list">
{#each appUsers.slice(0, 4) as user}
<UserAvatar {user} />
{/each}
</div>
<div class="text">
{appUsers.length}
{appUsers.length > 1 ? "users" : "user"} assigned
</div>
</div>
{/if}
{#if appGroups.length}
<div class="groups">
<div class="list">
{#each appGroups.slice(0, 4) as group}
<GroupIcon {group} />
{/each}
</div>
<div class="text">
{appGroups.length} user
{appGroups.length > 1 ? "groups" : "group"} assigned
</div>
</div>
{/if}
</div>
</Layout>
{:else}
<Layout noPadding gap="S">
<Body>No users</Body>
<div class="users-text">
No users have been assigned to this app
</div>
</Layout>
{/if}
</DashCard>
{/if}
</div>
{#if false}
<div class="bottom">
<DashCard
title={"Automation History"}
action={() => {
$goto("../automation-history")
}}
>
<div class="automation-content">
<div class="automation-metrics">
<div class="succeeded">
<Heading size="XL">0</Heading>
<div class="metric-info">
<Icon name="CheckmarkCircle" />
Success
</div>
</div>
<div class="failed">
<Heading size="XL">0</Heading>
<div class="metric-info">
<Icon name="Alert" />
Error
</div>
</div>
</div>
</div>
</DashCard>
<DashCard
title={"Backups"}
action={() => {
$goto("../backups")
}}
>
<div class="backups-content">test</div>
</DashCard>
</div>
{/if}
</Layout>
</div>
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
>
Are you sure you want to unpublish the app <b>{app?.name}</b>?
</ConfirmDialog>
<style>
.overview-tab {
display: grid;
}
.overview-tab .top {
display: grid;
grid-gap: var(--spectrum-alias-grid-gutter-medium);
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
}
.access-tab-content {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
gap: var(--spacing-xl);
flex-wrap: wrap;
}
.access-tab-content > * {
flex: 1 1 0;
}
.access-tab-content .list {
display: flex;
gap: 4px;
}
.access-tab-content .text {
color: var(--spectrum-global-color-gray-600);
margin-top: var(--spacing-xl);
}
.overview-tab .bottom,
.automation-metrics {
display: grid;
grid-gap: var(--spectrum-alias-grid-gutter-large);
grid-template-columns: 1fr 1fr;
}
.status-display {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.status-text,
.last-edit-text {
color: var(--spectrum-global-color-gray-600);
}
.updated-by {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.succeeded :global(.icon) {
color: var(--spectrum-global-color-green-600);
}
.failed :global(.icon) {
color: var(
--spectrum-semantic-negative-color-default,
var(--spectrum-global-color-red-500)
);
}
.metric-info {
display: flex;
gap: var(--spacing-l);
margin-top: var(--spacing-s);
}
.version-status,
.last-edit-text,
.status-text {
padding-top: var(--spacing-xl);
}
</style>

View File

@ -1,15 +0,0 @@
<script>
import { groups } from "stores/portal"
import { onMount } from "svelte"
let loaded = false
onMount(async () => {
await groups.actions.init()
loaded = true
})
</script>
{#if loaded}
<slot />
{/if}

View File

@ -277,7 +277,6 @@
allowClear={true}
/>
</div>
{#if !isCloud}
<div class="field">
<Label size="L">Title</Label>
<Input
@ -290,7 +289,6 @@
disabled={!brandingEnabled || saving}
/>
</div>
{/if}
<div>
<Toggle
text={"Remove Budibase brand from emails"}
@ -305,7 +303,6 @@
</div>
</div>
{#if !isCloud}
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">Login page</Heading>
@ -352,7 +349,6 @@
</div>
</div>
</div>
{/if}
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">Application previews</Heading>

View File

@ -141,7 +141,7 @@
customPlaceholder
allowEditRows={false}
customRenderers={customAppTableRenderers}
on:click={e => $goto(`../../overview/${e.detail.devId}`)}
on:click={e => $goto(`/builder/app/${e.detail.devId}`)}
>
<div class="placeholder" slot="placeholder">
<Heading size="S">This group doesn't have access to any apps</Heading>

View File

@ -346,7 +346,7 @@
customPlaceholder
allowEditRows={false}
customRenderers={customAppTableRenderers}
on:click={e => $goto(`../../overview/${e.detail.devId}`)}
on:click={e => $goto(`/builder/app/${e.detail.devId}`)}
>
<div class="placeholder" slot="placeholder">
<Heading size="S">

View File

@ -3,6 +3,7 @@
import PasswordCopyTableRenderer from "./PasswordCopyTableRenderer.svelte"
import { parseToCsv } from "helpers/data/utils"
import { onMount } from "svelte"
import InviteResponseRenderer from "./InviteResponseRenderer.svelte"
export let userData
export let createUsersResponse
@ -96,7 +97,7 @@
</script>
<ModalContent
size="M"
size="L"
{title}
confirmText="Done"
showCancelButton={false}
@ -113,6 +114,9 @@
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[
{ column: "reason", component: InviteResponseRenderer },
]}
/>
{/if}
{#if hasSuccess}

View File

@ -10,7 +10,6 @@ export { licensing } from "./licensing"
export { groups } from "./groups"
export { plugins } from "./plugins"
export { backups } from "./backups"
export { overview } from "./overview"
export { environment } from "./environment"
export { menu } from "./menu"
export { auditLogs } from "./auditLogs"

View File

@ -76,7 +76,13 @@ export const createLicensingStore = () => {
await actions.setQuotaUsage()
},
setNavigation: () => {
const upgradeUrl = `${get(admin).accountPortalUrl}/portal/upgrade`
const adminStore = get(admin)
const authStore = get(auth)
const upgradeUrl = authStore?.user?.accountPortalAccess
? `${adminStore.accountPortalUrl}/portal/upgrade`
: "/builder/portal/account/upgrade"
const goToUpgradePage = () => {
window.location.href = upgradeUrl
}

View File

@ -46,6 +46,7 @@
let dataLoaded = false
let permissionError = false
let embedNoScreens = false
// Determine if we should show devtools or not
$: showDevTools = $devToolsEnabled && !$routeStore.queryParams?.peek
@ -68,6 +69,8 @@
// If the user is logged in but has no screens, they don't have
// permission to use the app
permissionError = true
} else if ($appStore.embedded) {
embedNoScreens = true
} else {
// If they have no screens and are not logged in, it probably means
// they should log in to gain access
@ -86,7 +89,9 @@
if (get(builderStore).inBuilder) {
builderStore.actions.notifyLoaded()
} else {
builderStore.actions.analyticsPing({ source: "app" })
builderStore.actions.analyticsPing({
embedded: !!$appStore.embedded,
})
}
})
</script>
@ -158,6 +163,15 @@
</Body>
</Layout>
</div>
{:else if embedNoScreens}
<div class="error">
<Layout justifyItems="center" gap="S">
{@html ErrorSVG}
<Heading size="L">
This Budibase app is not publicly accessible
</Heading>
</Layout>
</div>
{:else}
<CustomThemeWrapper>
{#key $screenStore.activeLayout._id}

View File

@ -34,6 +34,8 @@
export let navWidth
export let pageWidth
export let embedded = false
const NavigationClasses = {
Top: "top",
Left: "left",
@ -186,9 +188,11 @@
<Heading size="S">{title}</Heading>
{/if}
</div>
{#if !embedded}
<div class="portal">
<Icon hoverable name="Apps" on:click={navigateToPortal} />
</div>
{/if}
</div>
<div
class="mobile-click-handler"

View File

@ -45,6 +45,11 @@ const loadBudibase = async () => {
// server rendered app HTML
appStore.actions.setAppId(window["##BUDIBASE_APP_ID##"])
// Set the flag used to determine if the app is being loaded via an iframe
appStore.actions.setAppEmbedded(
window["##BUDIBASE_APP_EMBEDDED##"] === "true"
)
// Fetch environment info
if (!get(environmentStore)?.loaded) {
await environmentStore.actions.fetchEnvironment()

View File

@ -5,6 +5,7 @@ const initialState = {
appId: null,
isDevApp: false,
clientLoadTime: window.INIT_TIME ? Date.now() - window.INIT_TIME : null,
embedded: false,
}
const createAppStore = () => {
@ -46,9 +47,20 @@ const createAppStore = () => {
})
}
const setAppEmbedded = embeddded => {
store.update(state => {
if (state) {
state.embedded = embeddded
} else {
state = { embeddded }
}
return state
})
}
return {
subscribe: derivedStore.subscribe,
actions: { setAppId, fetchAppDefinition },
actions: { setAppId, setAppEmbedded, fetchAppDefinition },
}
}

View File

@ -53,9 +53,9 @@ const createBuilderStore = () => {
notifyLoaded: () => {
eventStore.actions.dispatchEvent("preview-loaded")
},
analyticsPing: async () => {
analyticsPing: async ({ embedded }) => {
try {
await API.analyticsPing({ source: "app" })
await API.analyticsPing({ source: "app", embedded })
} catch (error) {
// Do nothing
}

View File

@ -33,7 +33,6 @@ const createScreenStore = () => {
]) => {
let activeLayout, activeScreen
let screens
if ($builderStore.inBuilder) {
// Use builder defined definitions if inside the builder preview
activeScreen = Helpers.cloneDeep($builderStore.screen)
@ -175,10 +174,10 @@ const createScreenStore = () => {
},
],
...navigationSettings,
embedded: $appStore.embedded,
},
}
}
return { screens, activeLayout, activeScreen }
}
)

View File

@ -7,11 +7,11 @@ export const buildAnalyticsEndpoints = API => ({
url: "/api/bbtel",
})
},
analyticsPing: async ({ source }) => {
analyticsPing: async ({ source, embedded }) => {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
return await API.post({
url: "/api/bbtel/ping",
body: { source, timezone },
body: { source, timezone, embedded },
})
},
})

View File

@ -12,6 +12,7 @@ export const isEnabled = async (ctx: any) => {
export const ping = async (ctx: any) => {
const body = ctx.request.body as AnalyticsPingRequest
switch (body.source) {
case PingSource.APP: {
const db = context.getAppDB({ skip_setup: true })
@ -21,7 +22,7 @@ export const ping = async (ctx: any) => {
if (isDevAppID(appId)) {
await events.serve.servedAppPreview(appInfo, body.timezone)
} else {
await events.serve.servedApp(appInfo, body.timezone)
await events.serve.servedApp(appInfo, body.timezone, body.embedded)
}
break
}

View File

@ -100,6 +100,9 @@ export const deleteObjects = async function (ctx: any) {
}
export const serveApp = async function (ctx: any) {
const bbHeaderEmbed =
ctx.request.get("x-budibase-embed")?.toLowerCase() === "true"
//Public Settings
const { config } = await configs.getSettingsConfigDoc()
const branding = await pro.branding.getBrandingConfig(config)
@ -140,6 +143,7 @@ export const serveApp = async function (ctx: any) {
body: html,
style: css.code,
appId,
embedded: bbHeaderEmbed,
})
} else {
// just return the app info for jest to assert on

View File

@ -1,4 +1,3 @@
<!doctype html>
<html>
<head>
@ -7,6 +6,7 @@
<script>
window["##BUDIBASE_APP_ID##"] = "{{appId}}"
window["##BUDIBASE_APP_EMBEDDED##"] = "{{embedded}}"
</script>
{{{body}}}

View File

@ -55,7 +55,22 @@ describe("/static", () => {
.expect(200)
expect(events.serve.servedApp).toBeCalledTimes(1)
expect(events.serve.servedApp).toBeCalledWith(config.getProdApp(), timezone)
expect(events.serve.servedApp).toBeCalledWith(config.getProdApp(), timezone, undefined)
expect(events.serve.servedAppPreview).not.toBeCalled()
})
it("should ping from an embedded app", async () => {
const headers = config.defaultHeaders()
headers[constants.Header.APP_ID] = config.prodAppId
await request
.post("/api/bbtel/ping")
.send({source: "app", timezone, embedded: true})
.set(headers)
.expect(200)
expect(events.serve.servedApp).toBeCalledTimes(1)
expect(events.serve.servedApp).toBeCalledWith(config.getProdApp(), timezone, true)
expect(events.serve.servedAppPreview).not.toBeCalled()
})
})

View File

@ -86,12 +86,6 @@ export default async function builder(ctx: UserCtx) {
const referer = ctx.headers["referer"]
const overviewPath = "/builder/portal/overview/"
const overviewContext = !referer ? false : referer.includes(overviewPath)
if (overviewContext) {
return
}
const hasAppId = !referer ? false : referer.includes(appId)
const editingApp = referer ? hasAppId : false
// check this is a builder call and editing

View File

@ -6,4 +6,5 @@ export enum PingSource {
export interface AnalyticsPingRequest {
source: PingSource
timezone: string
embedded?: boolean
}

View File

@ -7,6 +7,7 @@ export interface BuilderServedEvent extends BaseEvent {
export interface AppServedEvent extends BaseEvent {
appVersion: string
timezone: string
embed?: boolean
}
export interface AppPreviewServedEvent extends BaseEvent {