1
0
Fork 0
mirror of synced 2024-06-22 16:10:40 +12:00

Merge pull request #7782 from Budibase/feature/day-pass-pricing

Day pass pricing
This commit is contained in:
Rory Powell 2022-09-15 13:36:10 +01:00 committed by GitHub
commit 934bba9562
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
101 changed files with 1911 additions and 452 deletions

View file

@ -7,6 +7,7 @@ exports.Cookies = {
CurrentApp: "budibase:currentapp",
Auth: "budibase:auth",
Init: "budibase:init",
ACCOUNT_RETURN_URL: "budibase:account:returnurl",
DatasourceAuth: "budibase:datasourceauth",
OIDC_CONFIG: "budibase:oidc:config",
}

View file

@ -1,5 +1,6 @@
import { DocumentType, ViewName, DeprecatedViews, SEPARATOR } from "./utils"
import { getGlobalDB } from "../context"
import PouchDB from "pouchdb"
import { StaticDatabases } from "./constants"
import { doWithDB } from "./"
@ -201,13 +202,13 @@ export const queryView = async <T>(
try {
let response = await db.query<T, T>(`database/${viewName}`, params)
const rows = response.rows
const docs = rows.map((resp: any) =>
params.include_docs ? resp.doc : resp.value
)
const docs = rows.map(row => (params.include_docs ? row.doc : row.value))
// if arrayResponse has been requested, always return array regardless of length
if (opts?.arrayResponse) {
return docs
} else {
// return the single document if there is only one
return docs.length <= 1 ? docs[0] : docs
}
} catch (err: any) {

View file

@ -37,7 +37,7 @@ const env = {
MULTI_TENANCY: process.env.MULTI_TENANCY,
ACCOUNT_PORTAL_URL:
process.env.ACCOUNT_PORTAL_URL || "https://account.budibase.app",
ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY,
ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY || "",
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED || ""),
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,

View file

@ -1,11 +0,0 @@
class BudibaseError extends Error {
constructor(message, code, type) {
super(message)
this.code = code
this.type = type
}
}
module.exports = {
BudibaseError,
}

View file

@ -0,0 +1,10 @@
export class BudibaseError extends Error {
code: string
type: string
constructor(message: string, code: string, type: string) {
super(message)
this.code = code
this.type = type
}
}

View file

@ -1,11 +0,0 @@
const { BudibaseError } = require("./base")
class GenericError extends BudibaseError {
constructor(message, code, type) {
super(message, code, type ? type : "generic")
}
}
module.exports = {
GenericError,
}

View file

@ -0,0 +1,7 @@
import { BudibaseError } from "./base"
export class GenericError extends BudibaseError {
constructor(message: string, code: string, type: string) {
super(message, code, type ? type : "generic")
}
}

View file

@ -1,12 +0,0 @@
const { GenericError } = require("./generic")
class HTTPError extends GenericError {
constructor(message, httpStatus, code = "http", type = "generic") {
super(message, code, type)
this.status = httpStatus
}
}
module.exports = {
HTTPError,
}

View file

@ -0,0 +1,15 @@
import { GenericError } from "./generic"
export class HTTPError extends GenericError {
status: number
constructor(
message: string,
httpStatus: number,
code = "http",
type = "generic"
) {
super(message, code, type)
this.status = httpStatus
}
}

View file

@ -1,5 +1,6 @@
const http = require("./http")
const licensing = require("./licensing")
import { HTTPError } from "./http"
import { UsageLimitError, FeatureDisabledError } from "./licensing"
import * as licensing from "./licensing"
const codes = {
...licensing.codes,
@ -11,7 +12,7 @@ const context = {
...licensing.context,
}
const getPublicError = err => {
const getPublicError = (err: any) => {
let error
if (err.code || err.type) {
// add generic error information
@ -32,13 +33,15 @@ const getPublicError = err => {
return error
}
module.exports = {
const pkg = {
codes,
types,
errors: {
UsageLimitError: licensing.UsageLimitError,
FeatureDisabledError: licensing.FeatureDisabledError,
HTTPError: http.HTTPError,
UsageLimitError,
FeatureDisabledError,
HTTPError,
},
getPublicError,
}
export = pkg

View file

@ -1,43 +0,0 @@
const { HTTPError } = require("./http")
const type = "license_error"
const codes = {
USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded",
FEATURE_DISABLED: "feature_disabled",
}
const context = {
[codes.USAGE_LIMIT_EXCEEDED]: err => {
return {
limitName: err.limitName,
}
},
[codes.FEATURE_DISABLED]: err => {
return {
featureName: err.featureName,
}
},
}
class UsageLimitError extends HTTPError {
constructor(message, limitName) {
super(message, 400, codes.USAGE_LIMIT_EXCEEDED, type)
this.limitName = limitName
}
}
class FeatureDisabledError extends HTTPError {
constructor(message, featureName) {
super(message, 400, codes.FEATURE_DISABLED, type)
this.featureName = featureName
}
}
module.exports = {
type,
codes,
context,
UsageLimitError,
FeatureDisabledError,
}

View file

@ -0,0 +1,39 @@
import { HTTPError } from "./http"
export const type = "license_error"
export const codes = {
USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded",
FEATURE_DISABLED: "feature_disabled",
}
export const context = {
[codes.USAGE_LIMIT_EXCEEDED]: (err: any) => {
return {
limitName: err.limitName,
}
},
[codes.FEATURE_DISABLED]: (err: any) => {
return {
featureName: err.featureName,
}
},
}
export class UsageLimitError extends HTTPError {
limitName: string
constructor(message: string, limitName: string) {
super(message, 400, codes.USAGE_LIMIT_EXCEEDED, type)
this.limitName = limitName
}
}
export class FeatureDisabledError extends HTTPError {
featureName: string
constructor(message: string, featureName: string) {
super(message, 400, codes.FEATURE_DISABLED, type)
this.featureName = featureName
}
}

View file

@ -31,20 +31,26 @@ const TENANT_FEATURE_FLAGS = getFeatureFlags()
exports.isEnabled = featureFlag => {
const tenantId = tenancy.getTenantId()
return (
TENANT_FEATURE_FLAGS &&
TENANT_FEATURE_FLAGS[tenantId] &&
TENANT_FEATURE_FLAGS[tenantId].includes(featureFlag)
)
const flags = exports.getTenantFeatureFlags(tenantId)
return flags.includes(featureFlag)
}
exports.getTenantFeatureFlags = tenantId => {
if (TENANT_FEATURE_FLAGS && TENANT_FEATURE_FLAGS[tenantId]) {
return TENANT_FEATURE_FLAGS[tenantId]
const flags = []
if (TENANT_FEATURE_FLAGS) {
const globalFlags = TENANT_FEATURE_FLAGS["*"]
const tenantFlags = TENANT_FEATURE_FLAGS[tenantId]
if (globalFlags) {
flags.push(...globalFlags)
}
if (tenantFlags) {
flags.push(...tenantFlags)
}
}
return []
return flags
}
exports.FeatureFlag = {

View file

@ -1,5 +1,4 @@
import errors from "./errors"
const errorClasses = errors.errors
import * as events from "./events"
import * as migrations from "./migrations"
@ -15,7 +14,7 @@ import deprovisioning from "./context/deprovision"
import auth from "./auth"
import constants from "./constants"
import * as dbConstants from "./db/constants"
import logging from "./logging"
import * as logging from "./logging"
import pino from "./pino"
import * as middleware from "./middleware"
import plugins from "./plugin"

View file

@ -106,6 +106,7 @@ export = (
user = await getUser(userId, session.tenantId)
}
user.csrfToken = session.csrfToken
if (session?.lastAccessedAt < timeMinusOneMinute()) {
// make sure we denote that the session is still in use
await updateSessionTTL(session)

View file

@ -17,14 +17,6 @@ export const DEFINITIONS: MigrationDefinition[] = [
type: MigrationType.APP,
name: MigrationName.APP_URLS,
},
{
type: MigrationType.GLOBAL,
name: MigrationName.DEVELOPER_QUOTA,
},
{
type: MigrationType.GLOBAL,
name: MigrationName.PUBLISHED_APP_QUOTA,
},
{
type: MigrationType.APP,
name: MigrationName.EVENT_APP_BACKFILL,

View file

@ -2,28 +2,12 @@ const redis = require("../redis/init")
const { v4: uuidv4 } = require("uuid")
const { logWarn } = require("../logging")
const env = require("../environment")
interface CreateSession {
sessionId: string
tenantId: string
csrfToken?: string
}
interface Session extends CreateSession {
userId: string
lastAccessedAt: string
createdAt: string
// make optional attributes required
csrfToken: string
}
interface SessionKey {
key: string
}
interface ScannedSession {
value: Session
}
import {
Session,
ScannedSession,
SessionKey,
CreateSession,
} from "@budibase/types"
// a week in seconds
const EXPIRY_SECONDS = 86400 * 7

View file

@ -1,29 +1,39 @@
const {
import {
ViewName,
getUsersByAppParams,
getProdAppID,
generateAppUserID,
} = require("./db/utils")
const { queryGlobalView } = require("./db/views")
const { UNICODE_MAX } = require("./db/constants")
} from "./db/utils"
import { queryGlobalView } from "./db/views"
import { UNICODE_MAX } from "./db/constants"
import { User } from "@budibase/types"
/**
* Given an email address this will use a view to search through
* all the users to find one with this email address.
* @param {string} email the email to lookup the user by.
*/
exports.getGlobalUserByEmail = async email => {
export const getGlobalUserByEmail = async (
email: String
): Promise<User | undefined> => {
if (email == null) {
throw "Must supply an email address to view"
}
return await queryGlobalView(ViewName.USER_BY_EMAIL, {
const response = await queryGlobalView<User>(ViewName.USER_BY_EMAIL, {
key: email.toLowerCase(),
include_docs: true,
})
if (Array.isArray(response)) {
// shouldn't be able to happen, but need to handle just in case
throw new Error(`Multiple users found with email address: ${email}`)
}
return response
}
exports.searchGlobalUsersByApp = async (appId, opts) => {
export const searchGlobalUsersByApp = async (appId: any, opts: any) => {
if (typeof appId !== "string") {
throw new Error("Must provide a string based app ID")
}
@ -38,24 +48,24 @@ exports.searchGlobalUsersByApp = async (appId, opts) => {
return Array.isArray(response) ? response : [response]
}
exports.getGlobalUserByAppPage = (appId, user) => {
export const getGlobalUserByAppPage = (appId: string, user: User) => {
if (!user) {
return
}
return generateAppUserID(getProdAppID(appId), user._id)
return generateAppUserID(getProdAppID(appId), user._id!)
}
/**
* Performs a starts with search on the global email view.
*/
exports.searchGlobalUsersByEmail = async (email, opts) => {
export const searchGlobalUsersByEmail = async (email: string, opts: any) => {
if (typeof email !== "string") {
throw new Error("Must provide a string to search by")
}
const lcEmail = email.toLowerCase()
// handle if passing up startkey for pagination
const startkey = opts && opts.startkey ? opts.startkey : lcEmail
let response = await queryGlobalView(ViewName.USER_BY_EMAIL, {
let response = await queryGlobalView<User>(ViewName.USER_BY_EMAIL, {
...opts,
startkey,
endkey: `${lcEmail}${UNICODE_MAX}`,

View file

@ -42,6 +42,18 @@ async function resolveAppUrl(ctx) {
return app && app.appId ? app.appId : undefined
}
exports.isServingApp = ctx => {
// dev app
if (ctx.path.startsWith(`/${APP_PREFIX}`)) {
return true
}
// prod app
if (ctx.path.startsWith(PROD_APP_PREFIX)) {
return true
}
return false
}
/**
* Given a request tries to find the appId, which can be located in various places
* @param {object} ctx The main request body to look through.

View file

@ -4,22 +4,32 @@
import { banner } from "../Stores/banner"
import Banner from "./Banner.svelte"
import { fly } from "svelte/transition"
import TooltipWrapper from "../Tooltip/TooltipWrapper.svelte"
</script>
<Portal target=".banner-container">
<div class="banner">
{#if $banner.message}
{#each $banner.messages as message}
<div transition:fly={{ y: -30 }}>
<Banner
type={$banner.type}
extraButtonText={$banner.extraButtonText}
extraButtonAction={$banner.extraButtonAction}
on:change={$banner.onChange}
type={message.type}
extraButtonText={message.extraButtonText}
extraButtonAction={message.extraButtonAction}
on:change={() => {
if (message.onChange) {
message.onChange()
}
}}
showCloseButton={typeof message.showCloseButton === "boolean"
? message.showCloseButton
: true}
>
{$banner.message}
<TooltipWrapper tooltip={message.tooltip} disabled={false}>
{message.message}
</TooltipWrapper>
</Banner>
</div>
{/if}
{/each}
</div>
</Portal>

View file

@ -6,6 +6,7 @@
export let header = ""
export let message = ""
export let onConfirm = undefined
export let buttonText = ""
$: icon = selectIcon(type)
// if newlines used, convert them to different elements
@ -39,13 +40,16 @@
<div class="spectrum-InLineAlert-content">{splitMsg}</div>
{/each}
{#if onConfirm}
<div class="spectrum-InLineAlert-footer">
<Button secondary on:click={onConfirm}>OK</Button>
<div class="spectrum-InLineAlert-footer button">
<Button secondary on:click={onConfirm}>{buttonText || "OK"}</Button>
</div>
{/if}
</div>
<style>
.button {
margin-top: 10px;
}
.spectrum-InLineAlert {
--spectrum-semantic-negative-border-color: #e34850;
--spectrum-semantic-positive-border-color: #2d9d78;

View file

@ -8,6 +8,8 @@
export let duration = 1000
export let width = false
export let sideLabel = false
export let hidePercentage = true
export let color // red, green, default = blue
export let size = "M"
@ -37,7 +39,7 @@
<slot />
</div>
{/if}
{#if value || value === 0}
{#if !hidePercentage && (value || value === 0)}
<div
class="spectrum-FieldLabel spectrum-ProgressBar-percentage spectrum-FieldLabel--size{size}"
>
@ -47,8 +49,19 @@
<div class="spectrum-ProgressBar-track">
<div
class="spectrum-ProgressBar-fill"
class:color-green={color === "green"}
class:color-red={color === "red"}
style={value || value === 0 ? `width: ${$progress}%` : ""}
/>
</div>
<div class="spectrum-ProgressBar-label" hidden="" />
</div>
<style>
.color-green {
background: #009562;
}
.color-red {
background: #dd2019;
}
</style>

View file

@ -1,6 +1,7 @@
<script>
import { getContext } from "svelte"
const multilevel = getContext("sidenav-type")
import Badge from "../Badge/Badge.svelte"
export let href = ""
export let external = false
export let heading = ""
@ -8,6 +9,7 @@
export let selected = false
export let disabled = false
export let dataCy
export let badge = ""
</script>
<li
@ -38,10 +40,22 @@
</svg>
{/if}
<slot />
{#if badge}
<div class="badge">
<Badge active size="S">{badge}</Badge>
</div>
{/if}
</a>
{#if multilevel && $$slots.subnav}
<ul class="spectrum-SideNav">
<slot name="subnav" />
</ul>
{/if}
</li>
<style>
.badge {
margin-left: 10px;
}
</style>

View file

@ -1,7 +1,14 @@
import { writable } from "svelte/store"
export const BANNER_TYPES = {
INFO: "info",
NEGATIVE: "negative",
}
export function createBannerStore() {
const DEFAULT_CONFIG = {}
const DEFAULT_CONFIG = {
messages: [],
}
const banner = writable(DEFAULT_CONFIG)
@ -20,17 +27,38 @@ export function createBannerStore() {
const showStatus = async () => {
const config = {
message: "Some systems are experiencing issues",
type: "negative",
type: BANNER_TYPES.NEGATIVE,
extraButtonText: "View Status",
extraButtonAction: () => window.open("https://status.budibase.com/"),
}
await show(config)
await queue([config])
}
const queue = async entries => {
const priority = {
[BANNER_TYPES.NEGATIVE]: 0,
[BANNER_TYPES.INFO]: 1,
}
banner.update(store => {
const sorted = [...store.messages, ...entries].sort((a, b) => {
if (priority[a.type] == priority[b.type]) {
return 0
}
return priority[a.type] < priority[b.type] ? -1 : 1
})
return {
...store,
messages: sorted,
}
})
}
return {
subscribe: banner.subscribe,
showStatus,
show,
queue,
}
}

View file

@ -4,6 +4,7 @@
export let tooltip = ""
export let size = "M"
export let disabled = true
let showTooltip = false
</script>
@ -19,7 +20,7 @@
on:mouseleave={() => (showTooltip = false)}
on:focus
>
<Icon name="InfoOutline" size="S" disabled={true} />
<Icon name="InfoOutline" size="S" {disabled} />
</div>
{#if showTooltip}
<div class="tooltip">
@ -54,7 +55,6 @@
transform: scale(0.75);
}
.icon-small {
margin-top: -2px;
margin-bottom: -5px;
margin-bottom: -2px;
}
</style>

View file

@ -34,6 +34,7 @@ export { default as Layout } from "./Layout/Layout.svelte"
export { default as Page } from "./Layout/Page.svelte"
export { default as Link } from "./Link/Link.svelte"
export { default as Tooltip } from "./Tooltip/Tooltip.svelte"
export { default as TooltipWrapper } from "./Tooltip/TooltipWrapper.svelte"
export { default as Menu } from "./Menu/Menu.svelte"
export { default as MenuSection } from "./Menu/Section.svelte"
export { default as MenuSeparator } from "./Menu/Separator.svelte"
@ -94,7 +95,7 @@ export { default as clickOutside } from "./Actions/click_outside"
// Stores
export { notifications, createNotificationStore } from "./Stores/notifications"
export { banner } from "./Stores/banner"
export { banner, BANNER_TYPES } from "./Stores/banner"
// Helpers
export * as Helpers from "./helpers"

View file

@ -4,6 +4,7 @@
import { NotificationDisplay, BannerDisplay } from "@budibase/bbui"
import { parse, stringify } from "qs"
import HelpIcon from "components/common/HelpIcon.svelte"
import LicensingOverlays from "components/portal/licensing/LicensingOverlays.svelte"
const queryHandler = { parse, stringify }
</script>
@ -12,6 +13,9 @@
<BannerDisplay />
<NotificationDisplay />
<LicensingOverlays />
<Router {routes} config={{ queryHandler }} />
<div class="modal-container" />
<HelpIcon />

View file

@ -1,5 +1,6 @@
import { getFrontendStore } from "./store/frontend"
import { getAutomationStore } from "./store/automation"
import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme"
import { derived } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils"
@ -8,6 +9,7 @@ import { RoleUtils } from "@budibase/frontend-core"
export const store = getFrontendStore()
export const automationStore = getAutomationStore()
export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore()
export const selectedScreen = derived(store, $store => {
return $store.screens.find(screen => screen._id === $store.selectedScreenId)

View file

@ -0,0 +1,43 @@
import { createLocalStorageStore } from "@budibase/frontend-core"
import { get } from "svelte/store"
export const getTemporalStore = () => {
const initialValue = {}
const localStorageKey = `bb-temporal`
const store = createLocalStorageStore(localStorageKey, initialValue)
const setExpiring = (key, data, duration) => {
const updated = {
...data,
expiry: Date.now() + duration * 1000,
}
store.update(state => ({
...state,
[key]: updated,
}))
}
const getExpiring = key => {
const entry = get(store)[key]
if (!entry) {
return
}
const currentExpiry = entry.expiry
if (currentExpiry < Date.now()) {
store.update(state => {
delete state[key]
return state
})
return null
} else {
return entry
}
}
return {
subscribe: store.subscribe,
actions: { setExpiring, getExpiring },
}
}

View file

@ -10,6 +10,7 @@
} from "@budibase/bbui"
import TemplateCard from "components/common/TemplateCard.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte"
import { licensing } from "stores/portal"
export let templates
@ -96,15 +97,17 @@
backgroundColour={templateEntry.background}
icon={templateEntry.icon}
>
<Button
cta
on:click={() => {
template = templateEntry
creationModal.show()
}}
>
Use template
</Button>
{#if $licensing?.usageMetrics?.apps < 100}
<Button
cta
on:click={() => {
template = templateEntry
creationModal.show()
}}
>
Use template
</Button>
{/if}
<a
href={templateEntry.url}
target="_blank"

View file

@ -0,0 +1,49 @@
<script>
import { Modal, ModalContent, Body } from "@budibase/bbui"
import { auth, admin } from "stores/portal"
export let onDismiss = () => {}
export let onShow = () => {}
let accountDowngradeModal
$: accountUrl = $admin.accountPortalUrl
$: upgradeUrl = `${accountUrl}/portal/upgrade`
export function show() {
accountDowngradeModal.show()
}
export function hide() {
accountDowngradeModal.hide()
}
</script>
<Modal bind:this={accountDowngradeModal} on:show={onShow} on:hide={onDismiss}>
<ModalContent
title="Your account is now on the Free plan"
size="M"
showCancelButton={$auth.user.accountPortalAccess}
confirmText={$auth.user.accountPortalAccess ? "Upgrade" : "Confirm"}
onConfirm={$auth.user.accountPortalAccess
? () => {
window.location.href = upgradeUrl
}
: null}
>
<Body>
The payment for your subscription has failed and we have downgraded your
account to the <span class="free-plan">Free plan</span>.
</Body>
<Body>Upgrade to restore full functionality.</Body>
{#if !$auth.user.accountPortalAccess}
<Body>Please contact the account holder to upgrade.</Body>
{/if}
</ModalContent>
</Modal>
<style>
.free-plan {
font-weight: 600;
}
</style>

View file

@ -0,0 +1,47 @@
<script>
import { Modal, ModalContent, Body } from "@budibase/bbui"
import { auth, admin } from "stores/portal"
export let onDismiss = () => {}
let appLimitModal
$: accountUrl = $admin.accountPortalUrl
$: upgradeUrl = `${accountUrl}/portal/upgrade`
export function show() {
appLimitModal.show()
}
export function hide() {
appLimitModal.hide()
}
</script>
<Modal bind:this={appLimitModal} on:hide={onDismiss}>
<ModalContent
title="Upgrade to get more apps "
size="M"
showCancelButton={false}
confirmText={$auth.user.accountPortalAccess ? "Upgrade" : "Confirm"}
onConfirm={$auth.user.accountPortalAccess
? () => {
window.location.href = upgradeUrl
}
: null}
>
<Body>
You are currently on our <span class="free-plan">Free plan</span>. Upgrade
to our Pro plan to get unlimited apps and additional features.
</Body>
{#if !$auth.user.accountPortalAccess}
<Body>Please contact the account holder to upgrade.</Body>
{/if}
</ModalContent>
</Modal>
<style>
.free-plan {
font-weight: 600;
}
</style>

View file

@ -0,0 +1,78 @@
<script>
import { Modal, ModalContent, Body, TooltipWrapper } from "@budibase/bbui"
import { licensing, auth, admin } from "stores/portal"
export let onDismiss = () => {}
export let onShow = () => {}
let dayPassModal
$: accountUrl = $admin.accountPortalUrl
$: upgradeUrl = `${accountUrl}/portal/upgrade`
$: daysRemaining = $licensing.quotaResetDaysRemaining
$: quotaResetDate = $licensing.quotaResetDate
$: dayPassesUsed = $licensing.usageMetrics?.dayPasses
$: dayPassesTitle =
dayPassesUsed >= 100
? "You have run out of Day Passes"
: "You are almost out of Day Passes"
$: dayPassesBody =
dayPassesUsed >= 100
? "Upgrade your account to bring your apps back online."
: "Upgrade your account to prevent your apps from going offline."
export function show() {
dayPassModal.show()
}
export function hide() {
dayPassModal.hide()
}
</script>
<Modal bind:this={dayPassModal} on:show={onShow} on:hide={onDismiss}>
{#if $auth.user.accountPortalAccess}
<ModalContent
title={dayPassesTitle}
size="M"
confirmText="Upgrade"
onConfirm={() => {
window.location.href = upgradeUrl
}}
>
<Body>
You have used <span class="daypass_percent">{dayPassesUsed}%</span> of
your plans Day Passes with {daysRemaining} day{daysRemaining == 1
? ""
: "s"} remaining.
<span class="tooltip">
<TooltipWrapper tooltip={quotaResetDate} size="S" />
</span>
</Body>
<Body>{dayPassesBody}</Body>
</ModalContent>
{:else}
<ModalContent title={dayPassesTitle} size="M" showCancelButton={false}>
<Body>
You have used <span class="daypass_percent">{dayPassesUsed}%</span> of
your plans Day Passes with {daysRemaining} day{daysRemaining == 1
? ""
: "s"} remaining.
<span class="tooltip">
<TooltipWrapper tooltip={quotaResetDate} size="S" />
</span>
</Body>
<Body>Please contact your account holder to upgrade.</Body>
</ModalContent>
{/if}
</Modal>
<style>
.tooltip {
display: inline-block;
}
.tooltip :global(.icon-container) {
margin: 0px;
}
</style>

View file

@ -0,0 +1,112 @@
<script>
import { licensing, auth } from "stores/portal"
import { temporalStore } from "builderStore"
import { onMount } from "svelte"
import DayPassWarningModal from "./DayPassWarningModal.svelte"
import PaymentFailedModal from "./PaymentFailedModal.svelte"
import AccountDowngradedModal from "./AccountDowngradedModal.svelte"
import { ExpiringKeys } from "./constants"
import { getBanners } from "./licensingBanners"
import { banner } from "@budibase/bbui"
const oneDayInSeconds = 86400
let queuedBanners = []
let queuedModals = []
let dayPassModal
let paymentFailedModal
let accountDowngradeModal
let userLoaded = false
let loaded = false
let licensingLoaded = false
let currentModalCfg = null
const processModals = () => {
const defaultCacheFn = key => {
temporalStore.actions.setExpiring(key, {}, oneDayInSeconds)
}
const dismissableModals = [
{
key: ExpiringKeys.LICENSING_DAYPASS_WARNING_MODAL,
criteria: () => {
return $licensing?.usageMetrics?.dayPasses >= 90
},
action: () => {
dayPassModal.show()
},
cache: () => {
defaultCacheFn(ExpiringKeys.LICENSING_DAYPASS_WARNING_MODAL)
},
},
{
key: ExpiringKeys.LICENSING_PAYMENT_FAILED,
criteria: () => {
return $licensing.accountPastDue && !$licensing.isFreePlan()
},
action: () => {
paymentFailedModal.show()
},
cache: () => {
defaultCacheFn(ExpiringKeys.LICENSING_PAYMENT_FAILED)
},
},
{
key: ExpiringKeys.LICENSING_ACCOUNT_DOWNGRADED_MODAL,
criteria: () => {
return $licensing?.accountDowngraded
},
action: () => {
accountDowngradeModal.show()
},
cache: () => {
defaultCacheFn(ExpiringKeys.LICENSING_ACCOUNT_DOWNGRADED_MODAL)
},
},
]
return dismissableModals.filter(modal => {
return !temporalStore.actions.getExpiring(modal.key) && modal.criteria()
})
}
const showNextModal = () => {
if (currentModalCfg) {
currentModalCfg.cache()
}
if (queuedModals.length) {
currentModalCfg = queuedModals.shift()
currentModalCfg.action()
} else {
currentModalCfg = null
}
}
$: if (userLoaded && licensingLoaded && loaded) {
queuedModals = processModals()
queuedBanners = getBanners()
showNextModal()
banner.queue(queuedBanners)
}
onMount(async () => {
auth.subscribe(state => {
if (state.user && !userLoaded) {
userLoaded = true
}
})
licensing.subscribe(state => {
if (state.usageMetrics && !licensingLoaded) {
licensingLoaded = true
}
})
loaded = true
})
</script>
<DayPassWarningModal bind:this={dayPassModal} onDismiss={showNextModal} />
<PaymentFailedModal bind:this={paymentFailedModal} onDismiss={showNextModal} />
<AccountDowngradedModal
bind:this={accountDowngradeModal}
onDismiss={showNextModal}
/>

View file

@ -0,0 +1,84 @@
<script>
import { Modal, ModalContent, Body, TooltipWrapper } from "@budibase/bbui"
import { auth, admin, licensing } from "stores/portal"
import { onMount } from "svelte"
export let onDismiss = () => {}
export let onShow = () => {}
let paymentFailedModal
let pastDueEndDate
const paymentFailedTitle = "Payment failed"
$: accountUrl = $admin.accountPortalUrl
$: upgradeUrl = `${accountUrl}/portal/upgrade`
export function show() {
paymentFailedModal.show()
}
export function hide() {
paymentFailedModal.hide()
}
onMount(() => {
licensing.subscribe(state => {
pastDueEndDate = state.pastDueEndDate
})
})
</script>
<Modal bind:this={paymentFailedModal} on:show={onShow} on:hide={onDismiss}>
{#if $auth.user.accountPortalAccess}
<ModalContent
title={paymentFailedTitle}
size="M"
confirmText="Upgrade"
onConfirm={() => {
window.location.href = upgradeUrl
}}
>
<Body>The payment for your subscription has failed</Body>
<Body>
Please upgrade your billing details before your account gets downgraded
to the free plan
</Body>
<Body weight={800}>
<div class="tooltip-root">
{`${$licensing.pastDueDaysRemaining} day${
$licensing.pastDueDaysRemaining == 1 ? "" : "s"
} remaining`}
<span class="tooltip">
<TooltipWrapper tooltip={pastDueEndDate} size="S" />
</span>
</div>
</Body>
</ModalContent>
{:else}
<ModalContent title={paymentFailedTitle} size="M" showCancelButton={false}>
<Body>The payment for your subscription has failed</Body>
<Body>
Please upgrade your billing details before your account gets downgraded
to the free plan
</Body>
<Body>Please contact your account holder.</Body>
<Body weight={800}>
<div class="tooltip-root">
{`${$licensing.pastDueDaysRemaining} day${
$licensing.pastDueDaysRemaining == 1 ? "" : "s"
} remaining`}
<span class="tooltip">
<TooltipWrapper tooltip={pastDueEndDate} size="S" />
</span>
</div>
</Body>
</ModalContent>
{/if}
</Modal>
<style>
.tooltip-root {
display: flex;
align-items: center;
}
</style>

View file

@ -0,0 +1,15 @@
export const ExpiringKeys = {
LICENSING_DAYPASS_WARNING_MODAL: "licensing_daypass_warning_90_modal",
LICENSING_DAYPASS_WARNING_BANNER: "licensing_daypass_warning_90_banner",
LICENSING_PAYMENT_FAILED: "licensing_payment_failed",
LICENSING_ACCOUNT_DOWNGRADED_MODAL: "licensing_account_downgraded_modal",
LICENSING_APP_LIMIT_MODAL: "licensing_app_limit_modal",
LICENSING_ROWS_WARNING_BANNER: "licensing_rows_warning_banner",
LICENSING_AUTOMATIONS_WARNING_BANNER: "licensing_automations_warning_banner",
LICENSING_QUERIES_WARNING_BANNER: "licensing_queries_warning_banner",
}
export const StripeStatus = {
PAST_DUE: "past_due",
ACTIVE: "active",
}

View file

@ -0,0 +1,156 @@
import { ExpiringKeys } from "./constants"
import { temporalStore } from "builderStore"
import { admin, auth, licensing } from "stores/portal"
import { get } from "svelte/store"
import { BANNER_TYPES } from "@budibase/bbui"
const oneDayInSeconds = 86400
const defaultCacheFn = key => {
temporalStore.actions.setExpiring(key, {}, oneDayInSeconds)
}
const defaultAction = key => {
if (!get(auth).user.accountPortalAccess) {
return {}
}
return {
extraButtonText: "Upgrade Plan",
extraButtonAction: () => {
defaultCacheFn(key)
window.location.href = `${get(admin).accountPortalUrl}/portal/upgrade`
},
}
}
const buildUsageInfoBanner = (
metricKey,
metricLabel,
cacheKey,
percentageThreshold,
customMessage
) => {
const appAuth = get(auth)
const appLicensing = get(licensing)
const displayPercent =
appLicensing?.usageMetrics[metricKey] > 100
? 100
: appLicensing?.usageMetrics[metricKey]
let bannerConfig = {
key: cacheKey,
type: BANNER_TYPES.INFO,
onChange: () => {
defaultCacheFn(cacheKey)
},
message: customMessage
? customMessage
: `You have used ${displayPercent}% of your monthly usage of ${metricLabel} with ${
appLicensing.quotaResetDaysRemaining
} day${
appLicensing.quotaResetDaysRemaining == 1 ? "" : "s"
} remaining. ${
appAuth.user.accountPortalAccess
? ""
: "Please contact your account holder to upgrade"
}`,
criteria: () => {
return appLicensing?.usageMetrics[metricKey] >= percentageThreshold
},
tooltip: appLicensing?.quotaResetDate,
}
return !get(auth).user.accountPortalAccess
? bannerConfig
: {
...bannerConfig,
...defaultAction(cacheKey),
}
}
const buildDayPassBanner = () => {
const appAuth = get(auth)
const appLicensing = get(licensing)
if (get(licensing)?.usageMetrics["dayPasses"] >= 100) {
return {
key: "max_dayPasses",
type: BANNER_TYPES.NEGATIVE,
criteria: () => {
return true
},
message: `Your apps are currently offline. You have exceeded your plans limit for Day Passes. ${
appAuth.user.accountPortalAccess
? ""
: "Please contact your account holder to upgrade."
}`,
...defaultAction(),
showCloseButton: false,
}
}
return buildUsageInfoBanner(
"dayPasses",
"Day Passes",
ExpiringKeys.LICENSING_DAYPASS_WARNING_BANNER,
90,
`You have used ${
appLicensing?.usageMetrics["dayPasses"]
}% of your monthly usage of Day Passes with ${
appLicensing?.quotaResetDaysRemaining
} day${
get(licensing).quotaResetDaysRemaining == 1 ? "" : "s"
} remaining. All apps will be taken offline if this limit is reached. ${
appAuth.user.accountPortalAccess
? ""
: "Please contact your account holder to upgrade."
}`
)
}
const buildPaymentFailedBanner = () => {
return {
key: "payment_Failed",
type: BANNER_TYPES.NEGATIVE,
criteria: () => {
return get(licensing)?.accountPastDue && !get(licensing).isFreePlan()
},
message: `Payment Failed - Please update your billing details or your account will be downgrades in
${get(licensing)?.pastDueDaysRemaining} day${
get(licensing)?.pastDueDaysRemaining == 1 ? "" : "s"
}`,
...defaultAction(),
showCloseButton: false,
tooltip: get(licensing).pastDueEndDate,
}
}
export const getBanners = () => {
return [
buildPaymentFailedBanner(),
buildDayPassBanner(ExpiringKeys.LICENSING_DAYPASS_WARNING_BANNER),
buildUsageInfoBanner(
"rows",
"Rows",
ExpiringKeys.LICENSING_ROWS_WARNING_BANNER,
90
),
buildUsageInfoBanner(
"automations",
"Automations",
ExpiringKeys.LICENSING_AUTOMATIONS_WARNING_BANNER,
90
),
buildUsageInfoBanner(
"queries",
"Queries",
ExpiringKeys.LICENSING_QUERIES_WARNING_BANNER,
90
),
].filter(licensingBanner => {
return (
!temporalStore.actions.getExpiring(licensingBanner.key) &&
licensingBanner.criteria()
)
})
}

View file

@ -1,10 +1,15 @@
<script>
import { Body, ProgressBar, Label } from "@budibase/bbui"
import { Body, ProgressBar, Heading, Icon, Link } from "@budibase/bbui"
import { admin, auth } from "../../stores/portal"
import { onMount } from "svelte"
export let usage
export let warnWhenFull = false
let percentage
let unlimited = false
let showWarning = false
$: accountPortalAccess = $auth?.user?.accountPortalAccess
const isUnlimited = () => {
if (usage.total === -1) {
@ -14,29 +19,62 @@
}
const getPercentage = () => {
return Math.min(Math.ceil((usage.used / usage.total) * 100), 100)
return (usage.used / usage.total) * 100
}
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
onMount(() => {
unlimited = isUnlimited()
percentage = getPercentage()
if (warnWhenFull && percentage === 100) {
showWarning = true
}
})
</script>
<div class="usage">
<div class="info">
<Label size="XL">{usage.name}</Label>
<div class="header-container">
{#if showWarning}
<Icon name="Alert" />
{/if}
<div class="heading header-item">
<Heading size="XS" weight="light">{usage.name}</Heading>
</div>
</div>
{#if unlimited}
<Body size="S">{usage.used}</Body>
<Body size="S">{usage.used} / Unlimited</Body>
{:else}
<Body size="S">{usage.used} / {usage.total}</Body>
{/if}
</div>
<div>
{#if unlimited}
<Body size="S">Unlimited</Body>
<ProgressBar
showPercentage={false}
width={"100%"}
duration={1}
value={100}
/>
{:else}
<ProgressBar width={"100%"} duration={1} value={percentage} />
<ProgressBar
color={showWarning ? "red" : "green"}
showPercentage={false}
width={"100%"}
duration={1}
value={percentage}
/>
{/if}
{#if showWarning}
<Body size="S">
To get more {usage.name.toLowerCase()}
{#if accountPortalAccess}
<Link href={upgradeUrl}>upgrade your plan</Link>
{:else}
contact your account holder
{/if}
</Body>
{/if}
</div>
</div>
@ -51,6 +89,13 @@
display: flex;
flex-direction: row;
justify-content: space-between;
gap: var(--spacing-m);
margin-bottom: 12px;
}
.header-container {
display: flex;
}
.heading {
margin-top: 3px;
margin-left: 5px;
}
</style>

View file

@ -0,0 +1,119 @@
<script>
import {
Detail,
Button,
Heading,
Layout,
Body,
TooltipWrapper,
} from "@budibase/bbui"
export let description = ""
export let title = ""
export let primaryAction
export let secondaryAction
export let primaryActionText
export let secondaryActionText
export let primaryCta = true
export let textRows = []
$: primaryDefined = primaryAction && primaryActionText
$: secondaryDefined = secondaryAction && secondaryActionText
</script>
<div class="dash-card">
<div class="dash-card-header">
<div class="header-info">
<Layout gap="XS">
<div class="dash-card-title">
<Detail size="M">{description}</Detail>
</div>
<Heading size="M">{title}</Heading>
{#if textRows.length}
<div class="text-rows">
{#each textRows as row}
{#if row.tooltip}
<TooltipWrapper tooltip={row.tooltip}>
<Body>{row.message}</Body>
</TooltipWrapper>
{:else}
<Body>{row.message}</Body>
{/if}
{/each}
</div>
{/if}
</Layout>
</div>
<div class="header-actions">
{#if secondaryDefined}
<div>
<Button newStyles secondary on:click={secondaryAction}
>{secondaryActionText}</Button
>
</div>
{/if}
{#if primaryDefined}
<div class="primary-button">
<Button cta={primaryCta} on:click={primaryAction}
>{primaryActionText}</Button
>
</div>
{/if}
</div>
</div>
<div class="dash-card-body">
<slot />
</div>
</div>
<style>
.dash-card {
background: var(--spectrum-alias-background-color-primary);
border-radius: var(--border-radius-s);
overflow: hidden;
min-height: 150px;
}
.dash-card-header {
padding: 15px 25px 20px;
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
display: flex;
flex-direction: row;
justify-content: space-between;
}
.dash-card-body {
padding: 25px 30px;
}
.dash-card-title :global(.spectrum-Detail) {
color: var(
--spectrum-sidenav-heading-text-color,
var(--spectrum-global-color-gray-700)
);
display: inline-block;
}
.header-info {
flex: 1;
}
.header-actions {
flex: 1;
margin-top: 15px;
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.header-actions :global(:first-child) {
margin-right: 5px;
}
.text-rows {
margin-top: 10px;
}
@media only screen and (max-width: 900px) {
.dash-card-header {
flex-direction: column;
}
.header-actions {
justify-content: flex-start "bul";
}
}
</style>

View file

@ -0,0 +1,2 @@
export { default as Usage } from "./Usage.svelte"
export { default as DashCard } from "./UsageDashCard.svelte"

View file

@ -58,6 +58,13 @@ export const DefaultAppTheme = {
navTextColor: "var(--spectrum-global-color-gray-800)",
}
export const PlanType = {
FREE: "free",
PRO: "pro",
BUSINESS: "business",
ENTERPRISE: "enterprise",
}
export const PluginSource = {
URL: "URL",
NPM: "NPM",

View file

@ -1,6 +1,6 @@
<script>
import { isActive, redirect, params } from "@roxi/routify"
import { admin, auth } from "stores/portal"
import { admin, auth, licensing } from "stores/portal"
import { onMount } from "svelte"
import { CookieUtils, Constants } from "@budibase/frontend-core"
import { API } from "api"
@ -63,6 +63,10 @@
await auth.getSelf()
await admin.init()
if ($auth.user) {
await licensing.init()
}
// Set init info if present
if ($params["?template"]) {
await auth.setInitInfo({ init_template: $params["?template"] })

View file

@ -13,13 +13,14 @@
notifications,
} from "@budibase/bbui"
import { onMount } from "svelte"
import { apps, organisation, auth, groups } from "stores/portal"
import { apps, organisation, auth, groups, licensing } from "stores/portal"
import { goto } from "@roxi/routify"
import { AppStatus } from "constants"
import { gradient } from "actions"
import UpdateUserInfoModal from "components/settings/UpdateUserInfoModal.svelte"
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
import { processStringSync } from "@budibase/string-templates"
import Spaceman from "assets/bb-space-man.svg"
import Logo from "assets/bb-emblem.svg"
let loaded = false
@ -91,7 +92,7 @@
<div class="content">
<Layout noPadding>
<div class="header">
<img alt="logo" src={$organisation.logoUrl || Logo} />
<img class="logo" alt="logo" src={$organisation.logoUrl || Logo} />
<ActionMenu align="right" dataCy="user-menu">
<div slot="control" class="avatar">
<Avatar
@ -131,7 +132,17 @@
</Body>
</Layout>
<Divider />
{#if userApps.length}
{#if $licensing.usageMetrics.dayPasses >= 100}
<div>
<Layout gap="S" justifyItems="center">
<img class="spaceman" alt="spaceman" src={Spaceman} />
<Heading size="M">
{"Your apps are currently offline."}
</Heading>
Please contact the account holder to get them back online.
</Layout>
</div>
{:else if userApps.length}
<Heading>Apps</Heading>
<div class="group">
<Layout gap="S" noPadding>
@ -194,10 +205,13 @@
justify-content: space-between;
align-items: center;
}
img {
img.logo {
width: 40px;
margin-bottom: -12px;
}
img.spaceman {
width: 100px;
}
.avatar {
display: grid;
grid-template-columns: auto auto;

View file

@ -13,8 +13,7 @@
notifications,
} from "@budibase/bbui"
import ConfigChecklist from "components/common/ConfigChecklist.svelte"
import { organisation, auth } from "stores/portal"
import { admin as adminStore } from "stores/portal"
import { organisation, auth, admin as adminStore } from "stores/portal"
import { onMount } from "svelte"
import UpdateUserInfoModal from "components/settings/UpdateUserInfoModal.svelte"
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
@ -37,14 +36,6 @@
href: "/builder/portal/apps",
},
]
if (isEnabled(FEATURE_FLAGS.LICENSING)) {
menu = menu.concat([
{
title: "Usage",
href: "/builder/portal/settings/usage",
},
])
}
if (admin) {
menu = menu.concat([
@ -53,9 +44,20 @@
href: "/builder/portal/manage/users",
heading: "Manage",
},
isEnabled(FEATURE_FLAGS.USER_GROUPS)
? {
title: "User Groups",
href: "/builder/portal/manage/groups",
badge: "New",
}
: undefined,
{ title: "Auth", href: "/builder/portal/manage/auth" },
{ title: "Email", href: "/builder/portal/manage/email" },
{ title: "Plugins", href: "/builder/portal/manage/plugins" },
{
title: "Plugins",
href: "/builder/portal/manage/plugins",
badge: "New",
},
{
title: "Organisation",
@ -68,15 +70,6 @@
},
])
if (isEnabled(FEATURE_FLAGS.USER_GROUPS)) {
let item = {
title: "User Groups",
href: "/builder/portal/manage/groups",
}
menu.splice(2, 0, item)
}
if (!$adminStore.cloud) {
menu = menu.concat([
{
@ -84,13 +77,6 @@
href: "/builder/portal/settings/update",
},
])
if (isEnabled(FEATURE_FLAGS.LICENSING)) {
menu = menu.concat({
title: "Upgrade",
href: "/builder/portal/settings/upgrade",
})
}
}
} else {
menu = menu.concat([
@ -103,14 +89,62 @@
}
// add link to account portal if the user has access
if ($auth?.user?.accountPortalAccess) {
let accountSectionAdded = false
// link out to account-portal if account holder in cloud or always in self-host
if ($auth?.user?.accountPortalAccess || (!$adminStore.cloud && admin)) {
accountSectionAdded = true
menu = menu.concat([
{
title: "Account",
href: $adminStore.accountPortalUrl,
heading: "Account",
},
])
}
if (isEnabled(FEATURE_FLAGS.LICENSING)) {
// always show usage in self-host or cloud if licensing enabled
menu = menu.concat([
{
title: "Usage",
href: "/builder/portal/settings/usage",
heading: accountSectionAdded ? "" : "Account",
},
])
// show the relevant hosting upgrade page
if ($adminStore.cloud && $auth?.user?.accountPortalAccess) {
menu = menu.concat([
{
title: "Upgrade",
href: $adminStore.accountPortalUrl + "/portal/upgrade",
badge: "New",
},
])
} else if (!$adminStore.cloud && admin) {
menu = menu.concat({
title: "Upgrade",
href: "/builder/portal/settings/upgrade",
badge: "New",
})
}
// show the billing page to licensed account holders in cloud
if (
$auth?.user?.accountPortalAccess &&
$auth.user.account.stripeCustomerId
) {
menu = menu.concat([
{
title: "Billing",
href: $adminStore.accountPortalUrl + "/portal/billing",
},
])
}
}
menu = menu.filter(item => !!item)
return menu
}
@ -161,11 +195,12 @@
</div>
<div class="menu">
<Navigation>
{#each menu as { title, href, heading }}
{#each menu as { title, href, heading, badge }}
<Item
on:click={hideMobileMenu}
selected={$isActive(href)}
{href}
{badge}
{heading}>{title}</Item
>
{/each}

View file

@ -13,12 +13,14 @@
} from "@budibase/bbui"
import CreateAppModal from "components/start/CreateAppModal.svelte"
import TemplateDisplay from "components/common/TemplateDisplay.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import { onMount } from "svelte"
import { templates } from "stores/portal"
import { templates, licensing } from "stores/portal"
let loaded = $templates?.length
let template
let creationModal = false
let appLimitModal
let creatingApp = false
const welcomeBody =
@ -29,6 +31,8 @@
onMount(async () => {
try {
await templates.load()
await licensing.getQuotaUsage()
await licensing.getUsageMetrics()
if ($templates?.length === 0) {
notifications.error(
"There was a problem loading quick start templates."
@ -41,9 +45,13 @@
})
const initiateAppCreation = () => {
template = null
creationModal.show()
creatingApp = true
if ($licensing.usageMetrics.apps >= 100) {
appLimitModal.show()
} else {
template = null
creationModal.show()
creatingApp = true
}
}
const stopAppCreation = () => {
@ -52,9 +60,13 @@
}
const initiateAppImport = () => {
template = { fromFile: true }
creationModal.show()
creatingApp = true
if ($licensing.usageMetrics.apps >= 100) {
appLimitModal.show()
} else {
template = { fromFile: true }
creationModal.show()
creatingApp = true
}
}
</script>
@ -121,6 +133,7 @@
>
<CreateAppModal {template} />
</Modal>
<AppLimitModal bind:this={appLimitModal} />
<style>
.title .welcome > .buttons {

View file

@ -15,11 +15,12 @@
import Spinner from "components/common/Spinner.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import { store, automationStore } from "builderStore"
import { API } from "api"
import { onMount } from "svelte"
import { apps, auth, admin, templates } from "stores/portal"
import { apps, auth, admin, templates, licensing } from "stores/portal"
import download from "downloadjs"
import { goto } from "@roxi/routify"
import AppRow from "components/start/AppRow.svelte"
@ -32,6 +33,7 @@
let selectedApp
let creationModal
let updatingModal
let appLimitModal
let creatingApp = false
let loaded = $apps?.length || $templates?.length
let searchTerm = ""
@ -124,8 +126,10 @@
return `${app.name} - Automation error (${errorCount(errors)})`
}
const initiateAppCreation = () => {
if ($apps?.length) {
const initiateAppCreation = async () => {
if ($licensing.usageMetrics.apps >= 100) {
appLimitModal.show()
} else if ($apps?.length) {
$goto("/builder/portal/apps/create")
} else {
template = null
@ -225,6 +229,10 @@
try {
await apps.load()
await templates.load()
await licensing.getQuotaUsage()
await licensing.getUsageMetrics()
if ($templates?.length === 0) {
notifications.error(
"There was a problem loading quick start templates."
@ -405,6 +413,8 @@
<UpdateAppModal app={selectedApp} />
</Modal>
<AppLimitModal bind:this={appLimitModal} />
<style>
.appTable {
border-top: var(--border-light);

View file

@ -38,7 +38,11 @@
try {
await groups.actions.save(group)
} catch (error) {
notifications.error(`Failed to save group`)
if (error.status === 400) {
notifications.error(error.message)
} else {
notifications.error(`Failed to save group`)
}
}
}

View file

@ -35,10 +35,14 @@
}
const activate = async () => {
await API.activateLicenseKey({ licenseKey })
await auth.getSelf()
await setLicenseInfo()
notifications.success("Successfully activated")
try {
await API.activateLicenseKey({ licenseKey })
await auth.getSelf()
await setLicenseInfo()
notifications.success("Successfully activated")
} catch (e) {
notifications.error(e.message)
}
}
const refresh = async () => {

View file

@ -5,49 +5,74 @@
Heading,
Layout,
notifications,
Detail,
Link,
TooltipWrapper,
} from "@budibase/bbui"
import { onMount } from "svelte"
import { admin, auth, licensing } from "stores/portal"
import Usage from "components/usage/Usage.svelte"
import { admin, auth, licensing } from "../../../../stores/portal"
import { PlanType } from "../../../../constants"
import { DashCard, Usage } from "../../../../components/usage"
let staticUsage = []
let monthlyUsage = []
let cancelAt
let loaded = false
let textRows = []
let daysRemainingInMonth
let primaryActionText
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
const manageUrl = `${$admin.accountPortalUrl}/portal/billing`
const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes"]
const EXCLUDE_QUOTAS = ["Queries"]
$: quotaUsage = $licensing.quotaUsage
$: license = $auth.user?.license
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
$: accountPortalAccess = $auth?.user?.accountPortalAccess
$: quotaReset = quotaUsage?.quotaReset
const setMonthlyUsage = () => {
monthlyUsage = []
if (quotaUsage.monthly) {
for (let [key, value] of Object.entries(license.quotas.usage.monthly)) {
if (EXCLUDE_QUOTAS.includes(value.name)) {
continue
}
const used = quotaUsage.monthly.current[key]
if (used !== undefined) {
if (value.value !== 0) {
monthlyUsage.push({
name: value.name,
used: used,
used: used ? used : 0,
total: value.value,
})
}
}
}
monthlyUsage = monthlyUsage.sort((a, b) => a.name.localeCompare(b.name))
}
const setStaticUsage = () => {
staticUsage = []
for (let [key, value] of Object.entries(license.quotas.usage.static)) {
if (EXCLUDE_QUOTAS.includes(value.name)) {
continue
}
const used = quotaUsage.usageQuota[key]
if (used !== undefined) {
if (value.value !== 0) {
staticUsage.push({
name: value.name,
used: used,
used: used ? used : 0,
total: value.value,
})
}
}
staticUsage = staticUsage.sort((a, b) => a.name.localeCompare(b.name))
}
const setCancelAt = () => {
cancelAt = license?.billing?.subscription?.cancelAt
}
const capitalise = string => {
@ -56,6 +81,70 @@
}
}
const planTitle = () => {
return capitalise(license?.plan.type)
}
const getDaysRemaining = timestamp => {
if (!timestamp) {
return
}
const now = new Date()
now.setHours(0)
now.setMinutes(0)
const thenDate = new Date(timestamp)
thenDate.setHours(0)
thenDate.setMinutes(0)
const difference = thenDate.getTime() - now
// return the difference in days
return (difference / (1000 * 3600 * 24)).toFixed(0)
}
const setTextRows = () => {
textRows = []
if (cancelAt) {
textRows.push({ message: "Subscription has been cancelled" })
textRows.push({
message: `${getDaysRemaining(cancelAt * 1000)} days remaining`,
tooltip: new Date(cancelAt * 1000),
})
}
}
const setDaysRemainingInMonth = () => {
const resetDate = new Date(quotaReset)
const now = new Date()
const difference = resetDate.getTime() - now.getTime()
// return the difference in days
daysRemainingInMonth = (difference / (1000 * 3600 * 24)).toFixed(0)
}
const goToAccountPortal = () => {
if (license?.plan.type === PlanType.FREE) {
window.location.href = upgradeUrl
} else {
window.location.href = manageUrl
}
}
const setPrimaryActionText = () => {
if (license?.plan.type === PlanType.FREE) {
primaryActionText = "Upgrade"
return
}
if (cancelAt) {
primaryActionText = "Renew"
} else {
primaryActionText = "Manage"
}
}
const init = async () => {
try {
await licensing.getQuotaUsage()
@ -71,69 +160,98 @@
})
$: {
if (license && quotaUsage) {
setMonthlyUsage()
setStaticUsage()
if (license) {
setPrimaryActionText()
setCancelAt()
setTextRows()
setDaysRemainingInMonth()
if (quotaUsage) {
setMonthlyUsage()
setStaticUsage()
}
}
}
</script>
{#if loaded}
<Layout>
<Heading>Usage</Heading>
<Body
>Get information about your current usage within Budibase.
{#if $admin.cloud}
{#if $auth.user?.accountPortalAccess}
<Layout noPadding gap="S">
<Heading>Usage</Heading>
<Body
>Get information about your current usage within Budibase.
{#if accountPortalAccess}
To upgrade your plan and usage limits visit your <Link
size="L"
href={upgradeUrl}>Account</Link
>.
on:click={goToAccountPortal}
size="L">Account</Link
>
{:else}
Contact your account holder to upgrade your usage limits.
To upgrade your plan and usage limits contact your account holder
{/if}
{/if}
</Body>
</Layout>
<Layout gap="S">
<Divider size="S" />
</Layout>
<Layout gap="S" noPadding>
<Layout gap="XS">
<Body size="S">YOUR PLAN</Body>
<Heading size="S">{capitalise(license?.plan.type)}</Heading>
</Body>
</Layout>
<Layout gap="S">
<Body size="S">USAGE</Body>
<div class="usages">
{#each staticUsage as usage}
<div class="usage">
<Usage {usage} />
<Divider />
<DashCard
description="YOUR CURRENT PLAN"
title={planTitle()}
{primaryActionText}
primaryAction={accountPortalAccess ? goToAccountPortal : undefined}
{textRows}
>
<Layout gap="S" noPadding>
<Layout gap="S">
<div class="usages">
<Layout noPadding>
{#each staticUsage as usage}
<div class="usage">
<Usage
{usage}
warnWhenFull={WARN_USAGE.includes(usage.name)}
/>
</div>
{/each}
</Layout>
</div>
{/each}
</div>
</Layout>
{#if monthlyUsage.length}
<Layout gap="S">
<Body size="S">MONTHLY</Body>
<div class="usages">
{#each monthlyUsage as usage}
<div class="usage">
<Usage {usage} />
</div>
{/each}
</div>
</Layout>
{#if monthlyUsage.length}
<div class="monthly-container">
<Layout gap="S">
<Heading size="S" weight="light">Monthly</Heading>
<div class="detail">
<TooltipWrapper tooltip={new Date(quotaReset)}>
<Detail size="M">Resets in {daysRemainingInMonth} days</Detail
>
</TooltipWrapper>
</div>
<div class="usages">
<Layout noPadding>
{#each monthlyUsage as usage}
<div class="usage">
<Usage
{usage}
warnWhenFull={WARN_USAGE.includes(usage.name)}
/>
</div>
{/each}
</Layout>
</div>
</Layout>
</div>
{/if}
</Layout>
<div />
{/if}
</DashCard>
</Layout>
{/if}
<style>
.usages {
display: grid;
column-gap: 60px;
row-gap: 50px;
grid-template-columns: 1fr 1fr 1fr;
display: flex;
flex-direction: column;
}
.detail :global(.spectrum-Detail) {
color: var(--spectrum-global-color-gray-700);
}
.detail :global(.icon) {
margin-bottom: 0;
}
</style>

View file

@ -1,14 +1,22 @@
import { writable } from "svelte/store"
import { writable, get } from "svelte/store"
import { API } from "api"
import { auth } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
import { StripeStatus } from "components/portal/licensing/constants"
export const createLicensingStore = () => {
const DEFAULT = {
plans: {},
}
const oneDayInMilliseconds = 86400000
const store = writable(DEFAULT)
const actions = {
init: async () => {
await actions.getQuotaUsage()
await actions.getUsageMetrics()
},
getQuotaUsage: async () => {
const quotaUsage = await API.getQuotaUsage()
store.update(state => {
@ -18,6 +26,80 @@ export const createLicensingStore = () => {
}
})
},
getUsageMetrics: async () => {
const quota = get(store).quotaUsage
const license = get(auth).user.license
const now = new Date()
const getMetrics = (keys, license, quota) => {
if (!license || !quota || !keys) {
return {}
}
return keys.reduce((acc, key) => {
const quotaLimit = license[key].value
const quotaUsed = (quota[key] / quotaLimit) * 100
acc[key] = quotaLimit > -1 ? Math.round(quotaUsed) : -1
return acc
}, {})
}
const monthlyMetrics = getMetrics(
["dayPasses", "queries", "automations"],
license.quotas.usage.monthly,
quota.monthly.current
)
const staticMetrics = getMetrics(
["apps", "rows"],
license.quotas.usage.static,
quota.usageQuota
)
const getDaysBetween = (dateStart, dateEnd) => {
return dateEnd > dateStart
? Math.round(
(dateEnd.getTime() - dateStart.getTime()) / oneDayInMilliseconds
)
: 0
}
const quotaResetDate = new Date(quota.quotaReset)
const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate)
const accountDowngraded =
license?.billing?.subscription?.downgradeAt &&
license?.billing?.subscription?.downgradeAt <= now.getTime() &&
license?.billing?.subscription?.status === StripeStatus.PAST_DUE &&
license?.plan.type === Constants.PlanType.FREE
const pastDueAtMilliseconds = license?.billing?.subscription?.pastDueAt
const downgradeAtMilliseconds =
license?.billing?.subscription?.downgradeAt
let pastDueDaysRemaining
let pastDueEndDate
if (pastDueAtMilliseconds && downgradeAtMilliseconds) {
pastDueEndDate = new Date(downgradeAtMilliseconds)
pastDueDaysRemaining = getDaysBetween(
new Date(pastDueAtMilliseconds),
pastDueEndDate
)
}
store.update(state => {
return {
...state,
usageMetrics: { ...monthlyMetrics, ...staticMetrics },
quotaResetDaysRemaining,
quotaResetDate,
accountDowngraded,
accountPastDue: pastDueAtMilliseconds != null,
pastDueEndDate,
pastDueDaysRemaining,
isFreePlan: () => {
return license?.plan.type === Constants.PlanType.FREE
},
}
})
},
}
return {

View file

@ -1,15 +1,20 @@
<a
<script>
import { Link } from "@budibase/bbui"
</script>
<Link
href="https://www.budibase.com/?utm_source=budibase-apps-public-screens&utm_medium=badge&utm_campaign=made-in-budibase"
target="_blank"
>
<div>
<img src="https://i.imgur.com/Xhdt1YP.png" alt="Budibase" />
<p>Made In Budibase</p>
</div>
</a>
</Link>
<style>
div {
position: absolute;
position: fixed;
right: 20px;
bottom: 20px;
padding: 10px;
@ -27,12 +32,7 @@
p {
text-decoration: none;
color: var(--spectrum-heading-m-text-color);
}
a:visited {
text-decoration: none;
color: var(--spectrum-heading-m-text-color);
color: var(--spectrum-global-color-gray-900);
}
img {

View file

@ -5,9 +5,18 @@
import { FieldTypes } from "constants"
import active from "svelte-spa-router/active"
import { RoleUtils } from "@budibase/frontend-core"
import MadeInBudibase from "../MadeInBudibase.svelte"
import licensing from "../../licensing"
const sdk = getContext("sdk")
const { routeStore, styleable, linkable, builderStore, currentRole } = sdk
const {
routeStore,
styleable,
linkable,
builderStore,
currentRole,
environmentStore,
} = sdk
const component = getContext("component")
const context = getContext("context")
@ -225,6 +234,11 @@
</div>
</div>
{/if}
{#if !$builderStore.inBuilder && licensing.logoEnabled() && $environmentStore.cloud}
<MadeInBudibase />
{/if}
<div class="main-wrapper">
<div class="main size--{pageWidthClass}">
<slot />

View file

@ -0,0 +1,6 @@
export const PlanType = {
FREE: "free",
TEAM: "team",
BUSINESS: "business",
ENTERPRISE: "enterprise",
}

View file

@ -0,0 +1,5 @@
import { isFreePlan } from "./utils.js"
export const logoEnabled = () => {
return isFreePlan()
}

View file

@ -0,0 +1,7 @@
import * as features from "./features"
const licensing = {
...features,
}
export default licensing

View file

@ -0,0 +1,20 @@
import { authStore } from "../stores/auth.js"
import { get } from "svelte/store"
import { PlanType } from "./constants"
const getLicense = () => {
const user = get(authStore)
if (user) {
return user.license
}
}
export const isFreePlan = () => {
const license = getLicense()
if (license) {
return license.plan.type === PlanType.FREE
} else {
// safety net - no license means free plan
return true
}
}

View file

@ -9,6 +9,7 @@ import {
rowSelectionStore,
componentStore,
currentRole,
environmentStore,
} from "stores"
import { styleable } from "utils/styleable"
import { linkable } from "utils/linkable"
@ -27,6 +28,7 @@ export default {
builderStore,
uploadStore,
componentStore,
environmentStore,
currentRole,
styleable,
linkable,

View file

@ -0,0 +1,31 @@
import { API } from "api"
import { writable } from "svelte/store"
const initialState = {
cloud: false,
}
const createEnvironmentStore = () => {
const store = writable(initialState)
const actions = {
fetchEnvironment: async () => {
try {
const environment = await API.getEnvironment()
store.set({
...initialState,
...environment,
})
} catch (error) {
store.set(initialState)
}
},
}
return {
subscribe: store.subscribe,
actions,
}
}
export const environmentStore = createEnvironmentStore()

View file

@ -17,6 +17,7 @@ export { devToolsStore } from "./devTools"
export { componentStore } from "./components"
export { uploadStore } from "./uploads.js"
export { rowSelectionStore } from "./rowSelection.js"
export { environmentStore } from "./environment"
// Context stores are layered and duplicated, so it is not a singleton
export { createContextStore } from "./context"

View file

@ -1,7 +1,9 @@
import { routeStore } from "./routes"
import { appStore } from "./app"
import { environmentStore } from "./environment"
export async function initialise() {
await routeStore.actions.fetchRoutes()
await appStore.actions.fetchAppDefinition()
await environmentStore.actions.fetchEnvironment()
}

View file

@ -57,6 +57,7 @@ export const Cookies = {
Auth: "budibase:auth",
CurrentApp: "budibase:currentapp",
ReturnUrl: "budibase:returnurl",
AccountReturnUrl: "budibase:account:returnurl",
}
// Table names

View file

@ -17,6 +17,8 @@ const arg = process.argv.slice(2)[0]
*/
updateDotEnv({
ACCOUNT_PORTAL_URL:
arg === "enable" ? "http://local.com:10001" : "http://localhost:10001",
arg === "enable"
? "http://account.local.com:10001"
: "http://localhost:10001",
COOKIE_DOMAIN: arg === "enable" ? ".local.com" : "",
}).then(() => console.log("Updated worker!"))

View file

@ -472,7 +472,6 @@ const destroyApp = async (ctx: any) => {
const result = await db.destroy()
if (isUnpublish) {
await quotas.removePublishedApp()
await events.app.unpublished(app)
} else {
await quotas.removeApp()

View file

@ -4,6 +4,15 @@ const { getFullUser } = require("../../utilities/users")
const { BUILTIN_ROLE_IDS } = require("@budibase/backend-core/roles")
const { getAppDB, getAppId } = require("@budibase/backend-core/context")
/**
* Add the attributes that are session based to the current user.
*/
const addSessionAttributesToUser = ctx => {
if (ctx.user) {
ctx.body.license = ctx.user.license
}
}
exports.fetchSelf = async ctx => {
let userId = ctx.user.userId || ctx.user._id
/* istanbul ignore next */
@ -55,4 +64,6 @@ exports.fetchSelf = async ctx => {
} else {
ctx.body = user
}
addSessionAttributesToUser(ctx)
}

View file

@ -206,12 +206,7 @@ const _deployApp = async function (ctx: any) {
console.log("Deploying app...")
let app
if (await isFirstDeploy()) {
app = await quotas.addPublishedApp(() => deployApp(deployment))
} else {
app = await deployApp(deployment)
}
let app = await deployApp(deployment)
await events.app.published(app)
ctx.body = deployment

View file

@ -18,7 +18,6 @@ const { DocumentType } = require("../../../db/utils")
const { getAppDB, getAppId } = require("@budibase/backend-core/context")
const { setCookie, clearCookie } = require("@budibase/backend-core/utils")
const AWS = require("aws-sdk")
const fs = require("fs")
const {
downloadTarballDirect,

View file

@ -54,8 +54,8 @@ router
noTenancyRequired: true,
})
)
.use(currentApp)
.use(pro.licensing())
.use(currentApp)
.use(auditLog)
// error handling middleware

View file

@ -23,7 +23,6 @@ describe("/users", () => {
})
describe("fetch", () => {
it("returns a list of users from an instance db", async () => {
await config.createUser("uuidx")
await config.createUser("uuidy")
@ -38,7 +37,6 @@ describe("/users", () => {
expect(res.body.find(u => u._id === `ro_ta_users_us_uuidy`)).toBeDefined()
})
it("should apply authorization to endpoint", async () => {
await config.createUser()
await checkPermissionsEndpoint({

View file

@ -111,12 +111,14 @@ module.exports = async (ctx, next) => {
ctx.appId = appId
if (roleId) {
ctx.roleId = roleId
const globalId = ctx.user ? ctx.user._id : undefined
const userId = ctx.user ? generateUserMetadataID(ctx.user._id) : null
ctx.user = {
...ctx.user,
// override userID with metadata one
_id: userId,
userId,
globalId,
roleId,
role: await getRole(roleId),
}

View file

@ -35,12 +35,10 @@ const formatUsage = (usage: QuotaUsage) => {
let maxAutomations = 0
let maxQueries = 0
let rows = 0
let developers = 0
if (usage) {
if (usage.usageQuota) {
rows = usage.usageQuota.rows
developers = usage.usageQuota.developers
}
if (usage.monthly) {
@ -59,7 +57,6 @@ const formatUsage = (usage: QuotaUsage) => {
maxAutomations,
maxQueries,
rows,
developers,
}
}

View file

@ -1,15 +0,0 @@
const { createUserBuildersView } = require("@budibase/backend-core/db")
import * as syncDevelopers from "./usageQuotas/syncDevelopers"
/**
* Date:
* March 2022
*
* Description:
* Create the builder users view and sync the developer count
*/
export const run = async (db: any) => {
await createUserBuildersView(db)
await syncDevelopers.run()
}

View file

@ -1,13 +0,0 @@
import * as syncPublishedApps from "./usageQuotas/syncPublishedApps"
/**
* Date:
* March 2022
*
* Description:
* Sync the published apps count
*/
export const run = async (db: any) => {
await syncPublishedApps.run()
}

View file

@ -1,19 +0,0 @@
import { getTenantId } from "@budibase/backend-core/tenancy"
import { utils } from "@budibase/backend-core"
import { quotas, QuotaUsageType, StaticQuotaName } from "@budibase/pro"
export const run = async () => {
// get developer count
const developerCount = await utils.getBuildersCount()
// sync developer count
const tenantId = getTenantId()
console.log(
`[Tenant: ${tenantId}] Syncing developer count: ${developerCount}`
)
await quotas.setUsage(
developerCount,
StaticQuotaName.DEVELOPERS,
QuotaUsageType.STATIC
)
}

View file

@ -1,21 +0,0 @@
import { getTenantId } from "@budibase/backend-core/tenancy"
import { getAllApps } from "@budibase/backend-core/db"
import { quotas, QuotaUsageType, StaticQuotaName } from "@budibase/pro"
export const run = async () => {
// get app count
const opts: any = { dev: false }
const prodApps = await getAllApps(opts)
const prodAppCount = prodApps ? prodApps.length : 0
// sync app count
const tenantId = getTenantId()
console.log(
`[Tenant: ${tenantId}] Syncing published app count: ${prodAppCount}`
)
await quotas.setUsage(
prodAppCount,
StaticQuotaName.PUBLISHED_APPS,
QuotaUsageType.STATIC
)
}

View file

@ -6,8 +6,6 @@ import env from "../environment"
import * as userEmailViewCasing from "./functions/userEmailViewCasing"
import * as quota1 from "./functions/quotas1"
import * as appUrls from "./functions/appUrls"
import * as developerQuota from "./functions/developerQuota"
import * as publishedAppsQuota from "./functions/publishedAppsQuota"
import * as backfill from "./functions/backfill"
/**
@ -42,20 +40,6 @@ export const buildMigrations = () => {
})
break
}
case MigrationName.DEVELOPER_QUOTA: {
serverMigrations.push({
...definition,
fn: developerQuota.run,
})
break
}
case MigrationName.PUBLISHED_APP_QUOTA: {
serverMigrations.push({
...definition,
fn: publishedAppsQuota.run,
})
break
}
case MigrationName.EVENT_APP_BACKFILL: {
serverMigrations.push({
...definition,

View file

@ -14,6 +14,7 @@
"jest": {},
"devDependencies": {
"@types/node": "14.18.20",
"@types/koa": "2.13.4",
"rimraf": "3.0.2",
"typescript": "4.7.3"
}

View file

@ -0,0 +1 @@
export * from "./user"

View file

@ -0,0 +1,8 @@
export interface PostAccountUserActivity {
timestamp: number
}
export interface PostAccountUserActivityResponse {
userId: string
timestamp: number
}

View file

@ -1 +1,2 @@
export * from "./account"
export * from "./web"

View file

@ -22,11 +22,6 @@ export const isCreatePasswordAccount = (
account: CreateAccount
): account is CreatePassswordAccount => account.authType === AuthType.PASSWORD
export interface UpdateAccount {
stripeCustomerId?: string
licenseKey?: string
}
export interface Account extends CreateAccount {
// generated
accountId: string
@ -38,6 +33,7 @@ export interface Account extends CreateAccount {
tier: string // deprecated
stripeCustomerId?: string
licenseKey?: string
licenseKeyActivatedAt?: number
}
export interface PasswordAccount extends Account {

View file

@ -1 +1,2 @@
export * from "./account"
export * from "./user"

View file

@ -0,0 +1,10 @@
export interface CreateAccountUserActivity {
accountId: string
userId: string
timestamp: number
}
export interface AccountUserActivity extends CreateAccountUserActivity {
PK: string
SK: string
}

View file

@ -16,6 +16,7 @@ export interface User extends Document {
createdAt?: number // override the default createdAt behaviour - users sdk historically set this to Date.now()
userGroups?: string[]
forceResetPassword?: boolean
dayPassRecordedAt?: string
}
export interface UserRoles {

View file

@ -3,3 +3,25 @@ export interface AuthToken {
tenantId: string
sessionId: string
}
export interface CreateSession {
sessionId: string
tenantId: string
csrfToken?: string
}
export interface Session extends CreateSession {
userId: string
lastAccessedAt: string
createdAt: string
// make optional attributes required
csrfToken: string
}
export interface SessionKey {
key: string
}
export interface ScannedSession {
value: Session
}

View file

@ -5,4 +5,5 @@ export * from "./licensing"
export * from "./migrations"
export * from "./datasources"
export * from "./search"
export * from "./koa"
export * from "./auth"

View file

@ -0,0 +1,13 @@
import { Context } from "koa"
import { User } from "../documents"
import { License } from "../sdk"
export interface ContextUser extends User {
globalId?: string
license: License
}
export interface BBContext extends Context {
user?: ContextUser
body: any
}

View file

@ -0,0 +1,20 @@
import { PriceDuration } from "./plan"
export interface Customer {
balance: number | null | undefined
currency: string | null | undefined
}
export interface Subscription {
amount: number
quantity: number
duration: PriceDuration
cancelAt: number | null | undefined
currentPeriodStart: number
currentPeriodEnd: number
}
export interface Billing {
customer: Customer
subscription?: Subscription
}

View file

@ -0,0 +1,3 @@
export enum Feature {
USER_GROUPS = "userGroups",
}

View file

@ -1 +1,5 @@
export * from "./license"
export * from "./plan"
export * from "./quota"
export * from "./feature"
export * from "./billing"

View file

@ -1 +1,8 @@
export interface License {}
import { AccountPlan, Quotas, Feature, Billing } from "."
export interface License {
features: Feature[]
quotas: Quotas
plan: AccountPlan
billing?: Billing
}

View file

@ -0,0 +1,25 @@
export interface AccountPlan {
type: PlanType
price?: Price
}
export enum PlanType {
FREE = "free",
PRO = "pro",
BUSINESS = "business",
ENTERPRISE = "enterprise",
}
export enum PriceDuration {
MONTHLY = "monthly",
YEARLY = "yearly",
}
export interface Price {
amount: number
amountMonthly: number
currency: string
duration: PriceDuration
priceId: string
dayPasses: number
}

View file

@ -0,0 +1,82 @@
import { PlanType } from "."
export enum QuotaUsageType {
STATIC = "static",
MONTHLY = "monthly",
}
export enum QuotaType {
USAGE = "usage",
CONSTANT = "constant",
}
export enum StaticQuotaName {
ROWS = "rows",
APPS = "apps",
}
export enum MonthlyQuotaName {
QUERIES = "queries",
AUTOMATIONS = "automations",
DAY_PASSES = "dayPasses",
}
export enum ConstantQuotaName {
QUERY_TIMEOUT_SECONDS = "queryTimeoutSeconds",
AUTOMATION_LOG_RETENTION_DAYS = "automationLogRetentionDays",
}
export type QuotaName = StaticQuotaName | MonthlyQuotaName | ConstantQuotaName
export const isStaticQuota = (
quotaType: QuotaType,
usageType: QuotaUsageType,
name: QuotaName
): name is StaticQuotaName => {
return quotaType === QuotaType.USAGE && usageType === QuotaUsageType.STATIC
}
export const isMonthlyQuota = (
quotaType: QuotaType,
usageType: QuotaUsageType,
name: QuotaName
): name is MonthlyQuotaName => {
return quotaType === QuotaType.USAGE && usageType === QuotaUsageType.MONTHLY
}
export const isConstantQuota = (
quotaType: QuotaType,
name: QuotaName
): name is ConstantQuotaName => {
return quotaType === QuotaType.CONSTANT
}
export type PlanQuotas = {
[PlanType.FREE]: Quotas
[PlanType.PRO]: Quotas
[PlanType.BUSINESS]: Quotas
[PlanType.ENTERPRISE]: Quotas
}
export type Quotas = {
[QuotaType.USAGE]: {
[QuotaUsageType.MONTHLY]: {
[MonthlyQuotaName.QUERIES]: Quota
[MonthlyQuotaName.AUTOMATIONS]: Quota
[MonthlyQuotaName.DAY_PASSES]: Quota
}
[QuotaUsageType.STATIC]: {
[StaticQuotaName.ROWS]: Quota
[StaticQuotaName.APPS]: Quota
}
}
[QuotaType.CONSTANT]: {
[ConstantQuotaName.QUERY_TIMEOUT_SECONDS]: Quota
[ConstantQuotaName.AUTOMATION_LOG_RETENTION_DAYS]: Quota
}
}
export interface Quota {
name: string
value: number
}

View file

@ -41,8 +41,6 @@ export enum MigrationName {
USER_EMAIL_VIEW_CASING = "user_email_view_casing",
QUOTAS_1 = "quotas_1",
APP_URLS = "app_urls",
DEVELOPER_QUOTA = "developer_quota",
PUBLISHED_APP_QUOTA = "published_apps_quota",
EVENT_APP_BACKFILL = "event_app_backfill",
EVENT_GLOBAL_BACKFILL = "event_global_backfill",
EVENT_INSTALLATION_BACKFILL = "event_installation_backfill",

View file

@ -2,11 +2,145 @@
# yarn lockfile v1
"@types/accepts@*":
version "1.3.5"
resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575"
integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ==
dependencies:
"@types/node" "*"
"@types/body-parser@*":
version "1.19.2"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==
dependencies:
"@types/connect" "*"
"@types/node" "*"
"@types/connect@*":
version "3.4.35"
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==
dependencies:
"@types/node" "*"
"@types/content-disposition@*":
version "0.5.5"
resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.5.tgz#650820e95de346e1f84e30667d168c8fd25aa6e3"
integrity sha512-v6LCdKfK6BwcqMo+wYW05rLS12S0ZO0Fl4w1h4aaZMD7bqT3gVUns6FvLJKGZHQmYn3SX55JWGpziwJRwVgutA==
"@types/cookies@*":
version "0.7.7"
resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.7.tgz#7a92453d1d16389c05a5301eef566f34946cfd81"
integrity sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==
dependencies:
"@types/connect" "*"
"@types/express" "*"
"@types/keygrip" "*"
"@types/node" "*"
"@types/express-serve-static-core@^4.17.18":
version "4.17.29"
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.29.tgz#2a1795ea8e9e9c91b4a4bbe475034b20c1ec711c"
integrity sha512-uMd++6dMKS32EOuw1Uli3e3BPgdLIXmezcfHv7N4c1s3gkhikBplORPpMq3fuWkxncZN1reb16d5n8yhQ80x7Q==
dependencies:
"@types/node" "*"
"@types/qs" "*"
"@types/range-parser" "*"
"@types/express@*":
version "4.17.13"
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034"
integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==
dependencies:
"@types/body-parser" "*"
"@types/express-serve-static-core" "^4.17.18"
"@types/qs" "*"
"@types/serve-static" "*"
"@types/http-assert@*":
version "1.5.3"
resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.3.tgz#ef8e3d1a8d46c387f04ab0f2e8ab8cb0c5078661"
integrity sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA==
"@types/http-errors@*":
version "1.8.2"
resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.2.tgz#7315b4c4c54f82d13fa61c228ec5c2ea5cc9e0e1"
integrity sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==
"@types/keygrip@*":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72"
integrity sha512-GJhpTepz2udxGexqos8wgaBx4I/zWIDPh/KOGEwAqtuGDkOUJu5eFvwmdBX4AmB8Odsr+9pHCQqiAqDL/yKMKw==
"@types/koa-compose@*":
version "3.2.5"
resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.5.tgz#85eb2e80ac50be95f37ccf8c407c09bbe3468e9d"
integrity sha512-B8nG/OoE1ORZqCkBVsup/AKcvjdgoHnfi4pZMn5UwAPCbhk/96xyv284eBYW8JlQbQ7zDmnpFr68I/40mFoIBQ==
dependencies:
"@types/koa" "*"
"@types/koa@*":
version "2.13.5"
resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.5.tgz#64b3ca4d54e08c0062e89ec666c9f45443b21a61"
integrity sha512-HSUOdzKz3by4fnqagwthW/1w/yJspTgppyyalPVbgZf8jQWvdIXcVW5h2DGtw4zYntOaeRGx49r1hxoPWrD4aA==
dependencies:
"@types/accepts" "*"
"@types/content-disposition" "*"
"@types/cookies" "*"
"@types/http-assert" "*"
"@types/http-errors" "*"
"@types/keygrip" "*"
"@types/koa-compose" "*"
"@types/node" "*"
"@types/koa@2.13.4":
version "2.13.4"
resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.4.tgz#10620b3f24a8027ef5cbae88b393d1b31205726b"
integrity sha512-dfHYMfU+z/vKtQB7NUrthdAEiSvnLebvBjwHtfFmpZmB7em2N3WVQdHgnFq+xvyVgxW5jKDmjWfLD3lw4g4uTw==
dependencies:
"@types/accepts" "*"
"@types/content-disposition" "*"
"@types/cookies" "*"
"@types/http-assert" "*"
"@types/http-errors" "*"
"@types/keygrip" "*"
"@types/koa-compose" "*"
"@types/node" "*"
"@types/mime@^1":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
"@types/node@*":
version "18.0.6"
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.0.6.tgz#0ba49ac517ad69abe7a1508bc9b3a5483df9d5d7"
integrity sha512-/xUq6H2aQm261exT6iZTMifUySEt4GR5KX8eYyY+C4MSNPqSh9oNIP7tz2GLKTlFaiBbgZNxffoR3CVRG+cljw==
"@types/node@14.18.20":
version "14.18.20"
resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.20.tgz#268f028b36eaf51181c3300252f605488c4f0650"
integrity sha512-Q8KKwm9YqEmUBRsqJ2GWJDtXltBDxTdC4m5vTdXBolu2PeQh8LX+f6BTwU+OuXPu37fLxoN6gidqBmnky36FXA==
"@types/qs@*":
version "6.9.7"
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
"@types/range-parser@*":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/serve-static@*":
version "1.13.10"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9"
integrity sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==
dependencies:
"@types/mime" "^1"
"@types/node" "*"
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"

View file

@ -17,7 +17,9 @@ const arg = process.argv.slice(2)[0]
*/
updateDotEnv({
ACCOUNT_PORTAL_URL:
arg === "enable" ? "http://local.com:10001" : "http://localhost:10001",
arg === "enable"
? "http://account.local.com:10001"
: "http://localhost:10001",
COOKIE_DOMAIN: arg === "enable" ? ".local.com" : "",
PLATFORM_URL:
arg === "enable" ? "http://local.com:10000" : "http://localhost:10000",

View file

@ -144,6 +144,7 @@ exports.updateSelf = async ctx => {
}
// remove the old password from the user before sending events
user._rev = response.rev
delete user.password
await events.user.updated(user)
if (passwordChange) {

View file

@ -1,5 +1,5 @@
jest.mock("nodemailer")
import { TestConfiguration, API } from "../../../../tests"
import { TestConfiguration, API, mocks } from "../../../../tests"
import { events } from "@budibase/backend-core"
describe("/api/global/self", () => {
@ -26,6 +26,9 @@ describe("/api/global/self", () => {
delete user.password
const res = await api.self.updateSelf(user)
const dbUser = await config.getUser(user.email)
user._rev = dbUser._rev
user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString()
expect(res.body._id).toBe(user._id)
expect(events.user.updated).toBeCalledTimes(1)
expect(events.user.updated).toBeCalledWith(user)
@ -39,6 +42,9 @@ describe("/api/global/self", () => {
user.password = "newPassword"
const res = await api.self.updateSelf(user)
const dbUser = await config.getUser(user.email)
user._rev = dbUser._rev
user.dayPassRecordedAt = mocks.date.MOCK_DATE.toISOString()
delete user.password
expect(res.body._id).toBe(user._id)
expect(events.user.updated).toBeCalledTimes(1)

View file

@ -1,6 +1,7 @@
import env from "../../environment"
import { events, accounts, tenancy } from "@budibase/backend-core"
import { User, UserRoles, CloudAccount } from "@budibase/types"
import { users as pro } from "@budibase/pro"
export const handleDeleteEvents = async (user: any) => {
await events.user.deleted(user)
@ -51,7 +52,10 @@ const handleAppRoleEvents = async (user: any, existingUser: any) => {
await unassignAppRoleEvents(user, roles, existingRoles)
}
export const handleSaveEvents = async (user: any, existingUser: any) => {
export const handleSaveEvents = async (
user: User,
existingUser: User | undefined
) => {
const tenantId = tenancy.getTenantId()
let tenantAccount: CloudAccount | undefined
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {

View file

@ -1,37 +1,35 @@
import env from "../../environment"
import { quotas } from "@budibase/pro"
import * as apps from "../../utilities/appService"
import * as eventHelpers from "./events"
import {
tenancy,
utils,
db as dbUtils,
constants,
cache,
users as usersCore,
deprovisioning,
sessions,
HTTPError,
accounts,
migrations,
StaticDatabases,
ViewName,
cache,
constants,
db as dbUtils,
deprovisioning,
events,
HTTPError,
migrations,
sessions,
tenancy,
users as usersCore,
utils,
ViewName,
} from "@budibase/backend-core"
import {
MigrationType,
PlatformUserByEmail,
User,
BulkCreateUsersResponse,
CreateUserResponse,
BulkDeleteUsersResponse,
CloudAccount,
AllDocsResponse,
RowResponse,
BulkDocsResponse,
AccountMetadata,
AllDocsResponse,
BulkCreateUsersResponse,
BulkDeleteUsersResponse,
BulkDocsResponse,
CloudAccount,
CreateUserResponse,
InviteUsersRequest,
InviteUsersResponse,
MigrationType,
PlatformUserByEmail,
RowResponse,
User,
} from "@budibase/types"
import { groups as groupUtils } from "@budibase/pro"
import { sendEmail } from "../../utilities/email"
@ -120,7 +118,7 @@ interface SaveUserOpts {
}
const buildUser = async (
user: any,
user: User,
opts: SaveUserOpts = {
hashPassword: true,
requirePassword: true,
@ -230,16 +228,7 @@ export const save = async (
try {
// save the user to db
let response
const putUserFn = () => {
return db.put(builtUser)
}
if (eventHelpers.isAddingBuilder(builtUser, dbUser)) {
response = await quotas.addDeveloper(putUserFn)
} else {
response = await putUserFn()
}
let response = await db.put(builtUser)
builtUser._rev = response.rev
await eventHelpers.handleSaveEvents(builtUser, dbUser)
@ -388,13 +377,8 @@ export const bulkCreate = async (
newUsers.push(newUser)
}
// Figure out how many builders we are adding and create the promises
// array that will be called by bulkDocs
let builderCount = 0
// create the promises array that will be called by bulkDocs
newUsers.forEach((user: any) => {
if (eventHelpers.isAddingBuilder(user, null)) {
builderCount++
}
usersToSave.push(
buildUser(
user,
@ -408,14 +392,14 @@ export const bulkCreate = async (
})
const usersToBulkSave = await Promise.all(usersToSave)
await quotas.addDevelopers(() => db.bulkDocs(usersToBulkSave), builderCount)
await db.bulkDocs(usersToBulkSave)
// Post processing of bulk added users, i.e events and cache operations
for (const user of usersToBulkSave) {
// TODO: Refactor to bulk insert users into the info db
// instead of relying on looping tenant creation
await addTenant(tenantId, user._id, user.email)
await eventHelpers.handleSaveEvents(user, null)
await eventHelpers.handleSaveEvents(user, undefined)
await apps.syncUserInApps(user._id)
}
@ -475,8 +459,6 @@ export const bulkDelete = async (
}
let groupsToModify: any = {}
let builderCount = 0
// Get users and delete
const allDocsResponse: AllDocsResponse<User> = await db.allDocs({
include_docs: true,
@ -497,11 +479,6 @@ export const bulkDelete = async (
}
}
// Also figure out how many builders are being deleted
if (eventHelpers.isAddingBuilder(user.doc, null)) {
builderCount++
}
return user.doc
}
)
@ -519,7 +496,6 @@ export const bulkDelete = async (
for (let user of usersToDelete) {
await bulkDeleteProcessing(user)
}
await quotas.removeDevelopers(builderCount)
// Build Response
// index users by id
@ -574,7 +550,6 @@ export const destroy = async (id: string, currentUser: any) => {
}
await eventHelpers.handleDeleteEvents(dbUser)
await quotas.removeUser(dbUser)
await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" })
// let server know to sync user

View file

@ -2,10 +2,9 @@ import {
BulkCreateUsersRequest,
BulkCreateUsersResponse,
BulkDeleteUsersRequest,
CreateUserResponse,
BulkDeleteUsersResponse,
InviteUsersRequest,
User,
UserDetails,
} from "@budibase/types"
import TestConfiguration from "../TestConfiguration"

Some files were not shown because too many files have changed in this diff Show more