From ec06f13aa6a91c14dfdea07889109068579b43ac Mon Sep 17 00:00:00 2001 From: Rory Powell Date: Mon, 24 Apr 2023 09:31:48 +0100 Subject: [PATCH] Per user pricing (#10378) * Update pro version to 2.4.44-alpha.9 (#10231) Co-authored-by: Budibase Staging Release Bot <> * Track installation and unique tenant id on licence activate (#10146) * changes and exports * removing the extend * Lint + tidy * Update account.ts --------- Co-authored-by: Rory Powell Co-authored-by: mike12345567 * Type updates for loading new plans (#10245) * Add new quota for max users on free plan * Split available vs purchased plan & price type definitions. Update usages of available prices and plans * Type fixes * Add types for minimums * New `PlanModel` type for `PER_USER` and `DAY_PASS` (#10247) * Add new quota for max users on free plan * Split available vs purchased plan & price type definitions. Update usages of available prices and plans * Type fixes * Add types for minimums * New `PlanModel` type for `PER_USER` and `DAY_PASS` * Add loadEnvFiles to lerna config for run command to prevent local test failures * Fix types in license test structure * Add quotas integration to user create / delete + migration (#10250) * Add new quota for max users on free plan * Split available vs purchased plan & price type definitions. Update usages of available prices and plans * Type fixes * Add types for minimums * New `PlanModel` type for `PER_USER` and `DAY_PASS` * Add loadEnvFiles to lerna config for run command to prevent local test failures * Fix types in license test structure * Add quotas integration to user create / delete * Always sync user count from view total_rows value for accuracy * Add migration to sync users * Add syncUsers.spec.ts * Lint * Types and structures for user subscription quantity sync (#10280) * Add new quota for max users on free plan * Split available vs purchased plan & price type definitions. Update usages of available prices and plans * Type fixes * Add types for minimums * New `PlanModel` type for `PER_USER` and `DAY_PASS` * Add loadEnvFiles to lerna config for run command to prevent local test failures * Fix types in license test structure * Add quotas integration to user create / delete * Always sync user count from view total_rows value for accuracy * Add migration to sync users * Add syncUsers.spec.ts * Prevent old installs from activating, track install info via get license request instead of on activation. * Add usesInvoicing to PurchasedPlan * Add min/max users to PurchasedPlan * Additional test structures for generating a license, remove maxUsers from PurchasedPlan - this is already present in the license quotas * Stripe integration for monthly prorations on annual plans * Integrate annual prorations with test clocks * Updated types, test utils and date processing for licensing (#10346) * Add new quota for max users on free plan * Split available vs purchased plan & price type definitions. Update usages of available prices and plans * Type fixes * Add types for minimums * New `PlanModel` type for `PER_USER` and `DAY_PASS` * Add loadEnvFiles to lerna config for run command to prevent local test failures * Fix types in license test structure * Add quotas integration to user create / delete * Always sync user count from view total_rows value for accuracy * Add migration to sync users * Add syncUsers.spec.ts * Prevent old installs from activating, track install info via get license request instead of on activation. * Add usesInvoicing to PurchasedPlan * Add min/max users to PurchasedPlan * Additional test structures for generating a license, remove maxUsers from PurchasedPlan - this is already present in the license quotas * Stripe integration for monthly prorations on annual plans * Integrate annual prorations with test clocks * Updated types, test utils and date processing * Lint * Pricing/billing page (#10353) * bbui updates for billing page * Require all PlanTypes in PlanMinimums for compile time safety * fix test package utils * Incoming user limits warnings (#10379) * incoming user limits warning * fix inlinealert button * add corretc button link and text to user alert * pr comments * simplify limit check * Types and test updates for subscription quantity changes in account-portal (#10372) * Add chance extensions for `arrayOf`. Update events spies with license events * Add generics to doInTenant response * Update account structure with quota usage * User count limits (#10385) * incoming user limits warning * fix inlinealert button * add corretc button link and text to user alert * pr comments * simplify limit check * user limit messaging on add users modal * user limit messaging on import users modal * update licensing store to be more generic * some styling updates * remove console log * Store tweaks * Add startDate to Quota type --------- Co-authored-by: Rory Powell * Lint * Support custom lock options * Reactivity fixes for add user modals * Update ethereal email creds * Add warn for getting invite from code error * Extract disabling user import condition * Handling unlimited users in modals logic and adding start date processing to store * Lint * Integration testing fixes (#10389) * lint --------- Co-authored-by: Mateus Badan de Pieri Co-authored-by: mike12345567 Co-authored-by: Peter Clement --- packages/backend-core/src/constants/db.ts | 1 + .../backend-core/src/context/mainContext.ts | 6 +- packages/backend-core/src/db/views.ts | 91 +++++---- .../backend-core/src/events/identification.ts | 1 + packages/backend-core/src/installation.ts | 2 +- .../src/middleware/authenticated.ts | 2 +- .../backend-core/src/redis/redlockImpl.ts | 12 +- packages/backend-core/src/users.ts | 25 ++- packages/backend-core/src/utils/utils.ts | 26 +-- .../tests/core/utilities/index.ts | 2 +- .../tests/core/utilities/mocks/events.ts | 11 ++ .../tests/core/utilities/structures/Chance.ts | 20 ++ .../core/utilities/structures/accounts.ts | 3 +- .../core/utilities/structures/generator.ts | 2 +- .../tests/core/utilities/structures/index.ts | 1 + .../core/utilities/structures/licenses.ts | 132 ++++++++++++- .../tests/core/utilities/structures/quotas.ts | 67 +++++++ .../tests/core/utilities/utils/index.ts | 1 + .../tests/core/utilities/utils/time.ts | 3 + .../bbui/src/InlineAlert/InlineAlert.svelte | 7 +- packages/bbui/src/Layout/Page.svelte | 6 +- packages/bbui/src/Stores/banner.js | 4 +- packages/bbui/src/Typography/Detail.svelte | 7 +- .../common/users/UpgradeModal.svelte | 23 +++ .../components/portal/licensing/constants.js | 1 + .../portal/licensing/licensingBanners.js | 26 +++ packages/builder/src/constants/index.js | 5 + .../_components/BuilderSidePanel.svelte | 14 +- .../builder/portal/account/upgrade.svelte | 2 +- .../pages/builder/portal/account/usage.svelte | 28 ++- .../users/_components/AddUserModal.svelte | 30 ++- .../users/_components/ImportUsersModal.svelte | 25 ++- .../builder/portal/users/users/index.svelte | 53 +++++- .../builder/src/stores/portal/licensing.js | 55 +++++- packages/builder/src/stores/portal/users.js | 18 +- .../src/migrations/functions/syncQuotas.ts | 2 + .../functions/tests/syncQuotas.spec.js | 26 --- .../functions/usageQuotas/syncApps.ts | 3 +- .../functions/usageQuotas/syncUsers.ts | 9 + .../usageQuotas/tests/syncUsers.spec.ts | 26 +++ packages/server/src/migrations/index.ts | 1 + packages/types/package.json | 1 + packages/types/src/api/account/license.ts | 13 +- packages/types/src/api/web/user.ts | 20 +- .../types/src/documents/account/account.ts | 16 +- packages/types/src/documents/global/quotas.ts | 1 + packages/types/src/sdk/licensing/billing.ts | 1 + packages/types/src/sdk/licensing/feature.ts | 4 + packages/types/src/sdk/licensing/license.ts | 5 +- packages/types/src/sdk/licensing/plan.ts | 36 +++- packages/types/src/sdk/licensing/quota.ts | 15 +- packages/types/src/sdk/locks.ts | 9 + .../src/api/controllers/global/users.ts | 5 +- packages/worker/src/sdk/users/users.ts | 175 ++++++++++-------- packages/worker/src/utilities/email.ts | 4 +- scripts/link-dependencies.sh | 4 +- 56 files changed, 815 insertions(+), 273 deletions(-) create mode 100644 packages/backend-core/tests/core/utilities/structures/Chance.ts create mode 100644 packages/backend-core/tests/core/utilities/structures/quotas.ts create mode 100644 packages/backend-core/tests/core/utilities/utils/index.ts create mode 100644 packages/backend-core/tests/core/utilities/utils/time.ts create mode 100644 packages/builder/src/components/common/users/UpgradeModal.svelte delete mode 100644 packages/server/src/migrations/functions/tests/syncQuotas.spec.js create mode 100644 packages/server/src/migrations/functions/usageQuotas/syncUsers.ts create mode 100644 packages/server/src/migrations/functions/usageQuotas/tests/syncUsers.spec.ts diff --git a/packages/backend-core/src/constants/db.ts b/packages/backend-core/src/constants/db.ts index d41098c405..aa40f13775 100644 --- a/packages/backend-core/src/constants/db.ts +++ b/packages/backend-core/src/constants/db.ts @@ -14,6 +14,7 @@ export enum ViewName { USER_BY_APP = "by_app", USER_BY_EMAIL = "by_email2", BY_API_KEY = "by_api_key", + /** @deprecated - could be deleted */ USER_BY_BUILDERS = "by_builders", LINK = "by_link", ROUTING = "screen_routes", diff --git a/packages/backend-core/src/context/mainContext.ts b/packages/backend-core/src/context/mainContext.ts index 2f66c4bb7d..861777b679 100644 --- a/packages/backend-core/src/context/mainContext.ts +++ b/packages/backend-core/src/context/mainContext.ts @@ -115,10 +115,10 @@ export async function doInContext(appId: string, task: any): Promise { ) } -export async function doInTenant( +export async function doInTenant( tenantId: string | null, - task: any -): Promise { + task: () => T +): Promise { // make sure default always selected in single tenancy if (!env.MULTI_TENANCY) { tenantId = tenantId || DEFAULT_TENANT_ID diff --git a/packages/backend-core/src/db/views.ts b/packages/backend-core/src/db/views.ts index f0057cd7c3..fddb1ab34b 100644 --- a/packages/backend-core/src/db/views.ts +++ b/packages/backend-core/src/db/views.ts @@ -7,7 +7,7 @@ import { } from "../constants" import { getGlobalDB } from "../context" import { doWithDB } from "./" -import { Database, DatabaseQueryOpts } from "@budibase/types" +import { AllDocsResponse, Database, DatabaseQueryOpts } from "@budibase/types" import env from "../environment" const DESIGN_DB = "_design/database" @@ -119,6 +119,34 @@ export interface QueryViewOptions { arrayResponse?: boolean } +export async function queryViewRaw( + viewName: ViewName, + params: DatabaseQueryOpts, + db: Database, + createFunc: any, + opts?: QueryViewOptions +): Promise> { + try { + const response = await db.query(`database/${viewName}`, params) + // await to catch error + return response + } catch (err: any) { + const pouchNotFound = err && err.name === "not_found" + const couchNotFound = err && err.status === 404 + if (pouchNotFound || couchNotFound) { + await removeDeprecated(db, viewName) + await createFunc() + return queryViewRaw(viewName, params, db, createFunc, opts) + } else if (err.status === 409) { + // can happen when multiple queries occur at once, view couldn't be created + // other design docs being updated, re-run + return queryViewRaw(viewName, params, db, createFunc, opts) + } else { + throw err + } + } +} + export const queryView = async ( viewName: ViewName, params: DatabaseQueryOpts, @@ -126,34 +154,18 @@ export const queryView = async ( createFunc: any, opts?: QueryViewOptions ): Promise => { - try { - let response = await db.query(`database/${viewName}`, params) - const rows = response.rows - const docs = rows.map((row: any) => - params.include_docs ? row.doc : row.value - ) + const response = await queryViewRaw(viewName, params, db, createFunc, opts) + const rows = response.rows + const docs = rows.map((row: any) => + params.include_docs ? row.doc : row.value + ) - // if arrayResponse has been requested, always return array regardless of length - if (opts?.arrayResponse) { - return docs as T[] - } else { - // return the single document if there is only one - return docs.length <= 1 ? (docs[0] as T) : (docs as T[]) - } - } catch (err: any) { - const pouchNotFound = err && err.name === "not_found" - const couchNotFound = err && err.status === 404 - if (pouchNotFound || couchNotFound) { - await removeDeprecated(db, viewName) - await createFunc() - return queryView(viewName, params, db, createFunc, opts) - } else if (err.status === 409) { - // can happen when multiple queries occur at once, view couldn't be created - // other design docs being updated, re-run - return queryView(viewName, params, db, createFunc, opts) - } else { - throw err - } + // if arrayResponse has been requested, always return array regardless of length + if (opts?.arrayResponse) { + return docs as T[] + } else { + // return the single document if there is only one + return docs.length <= 1 ? (docs[0] as T) : (docs as T[]) } } @@ -208,18 +220,19 @@ export const queryPlatformView = async ( }) } +const CreateFuncByName: any = { + [ViewName.USER_BY_EMAIL]: createNewUserEmailView, + [ViewName.BY_API_KEY]: createApiKeyView, + [ViewName.USER_BY_BUILDERS]: createUserBuildersView, + [ViewName.USER_BY_APP]: createUserAppView, +} + export const queryGlobalView = async ( viewName: ViewName, params: DatabaseQueryOpts, db?: Database, opts?: QueryViewOptions ): Promise => { - const CreateFuncByName: any = { - [ViewName.USER_BY_EMAIL]: createNewUserEmailView, - [ViewName.BY_API_KEY]: createApiKeyView, - [ViewName.USER_BY_BUILDERS]: createUserBuildersView, - [ViewName.USER_BY_APP]: createUserAppView, - } // can pass DB in if working with something specific if (!db) { db = getGlobalDB() @@ -227,3 +240,13 @@ export const queryGlobalView = async ( const createFn = CreateFuncByName[viewName] return queryView(viewName, params, db!, createFn, opts) } + +export async function queryGlobalViewRaw( + viewName: ViewName, + params: DatabaseQueryOpts, + opts?: QueryViewOptions +) { + const db = getGlobalDB() + const createFn = CreateFuncByName[viewName] + return queryViewRaw(viewName, params, db, createFn, opts) +} diff --git a/packages/backend-core/src/events/identification.ts b/packages/backend-core/src/events/identification.ts index c85eb16a77..10b590b353 100644 --- a/packages/backend-core/src/events/identification.ts +++ b/packages/backend-core/src/events/identification.ts @@ -306,4 +306,5 @@ export default { identify, identifyGroup, getInstallationId, + getUniqueTenantId, } diff --git a/packages/backend-core/src/installation.ts b/packages/backend-core/src/installation.ts index 64be6f3f43..dd2461441c 100644 --- a/packages/backend-core/src/installation.ts +++ b/packages/backend-core/src/installation.ts @@ -33,7 +33,7 @@ async function createInstallDoc(platformDb: Database) { } } -const getInstallFromDB = async (): Promise => { +export const getInstallFromDB = async (): Promise => { return doWithDB( StaticDatabases.PLATFORM_INFO.name, async (platformDb: any) => { diff --git a/packages/backend-core/src/middleware/authenticated.ts b/packages/backend-core/src/middleware/authenticated.ts index f877985ee0..8bd6591d05 100644 --- a/packages/backend-core/src/middleware/authenticated.ts +++ b/packages/backend-core/src/middleware/authenticated.ts @@ -44,7 +44,7 @@ async function checkApiKey(apiKey: string, populateUser?: Function) { // check both the primary and the fallback internal api keys // this allows for rotation if (isValidInternalAPIKey(apiKey)) { - return { valid: true } + return { valid: true, user: undefined } } const decrypted = decrypt(apiKey) const tenantId = decrypted.split(SEPARATOR)[0] diff --git a/packages/backend-core/src/redis/redlockImpl.ts b/packages/backend-core/src/redis/redlockImpl.ts index 5e71488689..4e9cd569ed 100644 --- a/packages/backend-core/src/redis/redlockImpl.ts +++ b/packages/backend-core/src/redis/redlockImpl.ts @@ -1,10 +1,16 @@ -import Redlock, { Options } from "redlock" +import Redlock from "redlock" import { getLockClient } from "./init" import { LockOptions, LockType } from "@budibase/types" import * as context from "../context" import env from "../environment" -const getClient = async (type: LockType): Promise => { +const getClient = async ( + type: LockType, + opts?: Redlock.Options +): Promise => { + if (type === LockType.CUSTOM) { + return newRedlock(opts) + } if (env.isTest() && type !== LockType.TRY_ONCE) { return newRedlock(OPTIONS.TEST) } @@ -56,7 +62,7 @@ const OPTIONS = { }, } -const newRedlock = async (opts: Options = {}) => { +const newRedlock = async (opts: Redlock.Options = {}) => { let options = { ...OPTIONS.DEFAULT, ...opts } const redisWrapper = await getLockClient() const client = redisWrapper.getClient() diff --git a/packages/backend-core/src/users.ts b/packages/backend-core/src/users.ts index c7d8a94e95..166136df3c 100644 --- a/packages/backend-core/src/users.ts +++ b/packages/backend-core/src/users.ts @@ -1,15 +1,16 @@ import { - ViewName, - getUsersByAppParams, - getProdAppID, - generateAppUserID, - queryGlobalView, - UNICODE_MAX, - DocumentType, - SEPARATOR, directCouchFind, + DocumentType, + generateAppUserID, getGlobalUserParams, + getProdAppID, + getUsersByAppParams, pagination, + queryGlobalView, + queryGlobalViewRaw, + SEPARATOR, + UNICODE_MAX, + ViewName, } from "./db" import { BulkDocsResponse, SearchUsersRequest, User } from "@budibase/types" import { getGlobalDB } from "./context" @@ -239,3 +240,11 @@ export const paginatedUsers = async ({ getKey, }) } + +export async function getUserCount() { + const response = await queryGlobalViewRaw(ViewName.USER_BY_EMAIL, { + limit: 0, // to be as fast as possible - we just want the total rows count + include_docs: false, + }) + return response.total_rows +} diff --git a/packages/backend-core/src/utils/utils.ts b/packages/backend-core/src/utils/utils.ts index 7c222a9831..75b098093b 100644 --- a/packages/backend-core/src/utils/utils.ts +++ b/packages/backend-core/src/utils/utils.ts @@ -46,8 +46,9 @@ export async function resolveAppUrl(ctx: Ctx) { } // search prod apps for a url that matches - const apps: App[] = await context.doInTenant(tenantId, () => - getAllApps({ dev: false }) + const apps: App[] = await context.doInTenant( + tenantId, + () => getAllApps({ dev: false }) as Promise ) const app = apps.filter( a => a.url && a.url.toLowerCase() === possibleAppUrl @@ -221,27 +222,6 @@ export function isClient(ctx: Ctx) { return ctx.headers[Header.TYPE] === "client" } -async function getBuilders() { - const builders = await queryGlobalView(ViewName.USER_BY_BUILDERS, { - include_docs: false, - }) - - if (!builders) { - return [] - } - - if (Array.isArray(builders)) { - return builders - } else { - return [builders] - } -} - -export async function getBuildersCount() { - const builders = await getBuilders() - return builders.length -} - export function timeout(timeMs: number) { return new Promise(resolve => setTimeout(resolve, timeMs)) } diff --git a/packages/backend-core/tests/core/utilities/index.ts b/packages/backend-core/tests/core/utilities/index.ts index eee41cc3d4..787d69be2c 100644 --- a/packages/backend-core/tests/core/utilities/index.ts +++ b/packages/backend-core/tests/core/utilities/index.ts @@ -2,5 +2,5 @@ export * as mocks from "./mocks" export * as structures from "./structures" export { generator } from "./structures" export * as testContainerUtils from "./testContainerUtils" - +export * as utils from "./utils" export * from "./jestUtils" diff --git a/packages/backend-core/tests/core/utilities/mocks/events.ts b/packages/backend-core/tests/core/utilities/mocks/events.ts index 17e35a5d0c..dacf7dcce8 100644 --- a/packages/backend-core/tests/core/utilities/mocks/events.ts +++ b/packages/backend-core/tests/core/utilities/mocks/events.ts @@ -1,3 +1,5 @@ +import * as events from "../../../../src/events" + beforeAll(async () => { const processors = await import("../../../../src/events/processors") const events = await import("../../../../src/events") @@ -120,4 +122,13 @@ beforeAll(async () => { jest.spyOn(events.plugin, "init") jest.spyOn(events.plugin, "imported") jest.spyOn(events.plugin, "deleted") + + jest.spyOn(events.license, "tierChanged") + jest.spyOn(events.license, "planChanged") + jest.spyOn(events.license, "activated") + jest.spyOn(events.license, "checkoutOpened") + jest.spyOn(events.license, "checkoutSuccess") + jest.spyOn(events.license, "portalOpened") + jest.spyOn(events.license, "paymentFailed") + jest.spyOn(events.license, "paymentRecovered") }) diff --git a/packages/backend-core/tests/core/utilities/structures/Chance.ts b/packages/backend-core/tests/core/utilities/structures/Chance.ts new file mode 100644 index 0000000000..73d7ba102f --- /dev/null +++ b/packages/backend-core/tests/core/utilities/structures/Chance.ts @@ -0,0 +1,20 @@ +import Chance from "chance" + +export default class CustomChance extends Chance { + arrayOf( + generateFn: () => T, + opts: { min?: number; max?: number } = {} + ): T[] { + const itemCount = this.integer({ + min: opts.min != null ? opts.min : 1, + max: opts.max != null ? opts.max : 50, + }) + + const items = [] + for (let i = 0; i < itemCount; i++) { + items.push(generateFn()) + } + + return items + } +} diff --git a/packages/backend-core/tests/core/utilities/structures/accounts.ts b/packages/backend-core/tests/core/utilities/structures/accounts.ts index 30ef6e4192..807153cd09 100644 --- a/packages/backend-core/tests/core/utilities/structures/accounts.ts +++ b/packages/backend-core/tests/core/utilities/structures/accounts.ts @@ -1,4 +1,4 @@ -import { generator, uuid } from "." +import { generator, uuid, quotas } from "." import { generateGlobalUserID } from "../../../../src/docIds" import { Account, @@ -28,6 +28,7 @@ export const account = (): Account => { name: generator.name(), size: "10+", profession: "Software Engineer", + quotaUsage: quotas.usage(), } } diff --git a/packages/backend-core/tests/core/utilities/structures/generator.ts b/packages/backend-core/tests/core/utilities/structures/generator.ts index 51567b152e..ed4dac8255 100644 --- a/packages/backend-core/tests/core/utilities/structures/generator.ts +++ b/packages/backend-core/tests/core/utilities/structures/generator.ts @@ -1,2 +1,2 @@ -import Chance from "chance" +import Chance from "./Chance" export const generator = new Chance() diff --git a/packages/backend-core/tests/core/utilities/structures/index.ts b/packages/backend-core/tests/core/utilities/structures/index.ts index 5592a7e1f9..2c094f43a7 100644 --- a/packages/backend-core/tests/core/utilities/structures/index.ts +++ b/packages/backend-core/tests/core/utilities/structures/index.ts @@ -11,3 +11,4 @@ export * as users from "./users" export * as userGroups from "./userGroups" export { generator } from "./generator" export * as scim from "./scim" +export * as quotas from "./quotas" diff --git a/packages/backend-core/tests/core/utilities/structures/licenses.ts b/packages/backend-core/tests/core/utilities/structures/licenses.ts index a541e91860..24b120451e 100644 --- a/packages/backend-core/tests/core/utilities/structures/licenses.ts +++ b/packages/backend-core/tests/core/utilities/structures/licenses.ts @@ -1,18 +1,132 @@ -import { AccountPlan, License, PlanType, Quotas } from "@budibase/types" +import { + Billing, + Customer, + Feature, + License, + PlanModel, + PlanType, + PriceDuration, + PurchasedPlan, + Quotas, + Subscription, +} from "@budibase/types" -const newPlan = (type: PlanType = PlanType.FREE): AccountPlan => { +export const plan = (type: PlanType = PlanType.FREE): PurchasedPlan => { return { type, + usesInvoicing: false, + minUsers: 1, + model: PlanModel.PER_USER, } } -export const newLicense = (opts: { - quotas: Quotas - planType?: PlanType -}): License => { +export function quotas(): Quotas { return { - features: [], - quotas: opts.quotas, - plan: newPlan(opts.planType), + usage: { + monthly: { + queries: { + name: "Queries", + value: 1, + triggers: [], + }, + automations: { + name: "Queries", + value: 1, + triggers: [], + }, + dayPasses: { + name: "Queries", + value: 1, + triggers: [], + }, + }, + static: { + rows: { + name: "Rows", + value: 1, + triggers: [], + }, + apps: { + name: "Apps", + value: 1, + triggers: [], + }, + users: { + name: "Users", + value: 1, + triggers: [], + }, + userGroups: { + name: "User Groups", + value: 1, + triggers: [], + }, + plugins: { + name: "Plugins", + value: 1, + triggers: [], + }, + }, + }, + constant: { + automationLogRetentionDays: { + name: "Automation Logs", + value: 1, + triggers: [], + }, + appBackupRetentionDays: { + name: "Backups", + value: 1, + triggers: [], + }, + }, + } +} + +export function billing( + opts: { customer?: Customer; subscription?: Subscription } = {} +): Billing { + return { + customer: opts.customer || customer(), + subscription: opts.subscription || subscription(), + } +} + +export function customer(): Customer { + return { + balance: 0, + currency: "usd", + } +} + +export function subscription(): Subscription { + return { + amount: 10000, + cancelAt: undefined, + currency: "usd", + currentPeriodEnd: 0, + currentPeriodStart: 0, + downgradeAt: 0, + duration: PriceDuration.MONTHLY, + pastDueAt: undefined, + quantity: 0, + status: "active", + } +} + +export const license = ( + opts: { + quotas?: Quotas + plan?: PurchasedPlan + planType?: PlanType + features?: Feature[] + billing?: Billing + } = {} +): License => { + return { + features: opts.features || [], + quotas: opts.quotas || quotas(), + plan: opts.plan || plan(opts.planType), + billing: opts.billing || billing(), } } diff --git a/packages/backend-core/tests/core/utilities/structures/quotas.ts b/packages/backend-core/tests/core/utilities/structures/quotas.ts new file mode 100644 index 0000000000..e82117053f --- /dev/null +++ b/packages/backend-core/tests/core/utilities/structures/quotas.ts @@ -0,0 +1,67 @@ +import { MonthlyQuotaName, QuotaUsage } from "@budibase/types" + +export const usage = (): QuotaUsage => { + return { + _id: "usage_quota", + quotaReset: new Date().toISOString(), + apps: { + app_1: { + // @ts-ignore - the apps definition doesn't match up to actual usage + usageQuota: { + rows: 0, + }, + }, + }, + monthly: { + "01-2023": { + automations: 0, + dayPasses: 0, + queries: 0, + triggers: {}, + breakdown: { + rowQueries: { + parent: MonthlyQuotaName.QUERIES, + values: { + row_1: 0, + row_2: 0, + }, + }, + datasourceQueries: { + parent: MonthlyQuotaName.QUERIES, + values: { + ds_1: 0, + ds_2: 0, + }, + }, + automations: { + parent: MonthlyQuotaName.AUTOMATIONS, + values: { + auto_1: 0, + auto_2: 0, + }, + }, + }, + }, + "02-2023": { + automations: 0, + dayPasses: 0, + queries: 0, + triggers: {}, + }, + current: { + automations: 0, + dayPasses: 0, + queries: 0, + triggers: {}, + }, + }, + usageQuota: { + apps: 0, + plugins: 0, + users: 0, + userGroups: 0, + rows: 0, + triggers: {}, + }, + } +} diff --git a/packages/backend-core/tests/core/utilities/utils/index.ts b/packages/backend-core/tests/core/utilities/utils/index.ts new file mode 100644 index 0000000000..41a249c7e6 --- /dev/null +++ b/packages/backend-core/tests/core/utilities/utils/index.ts @@ -0,0 +1 @@ +export * as time from "./time" diff --git a/packages/backend-core/tests/core/utilities/utils/time.ts b/packages/backend-core/tests/core/utilities/utils/time.ts new file mode 100644 index 0000000000..c71167bc72 --- /dev/null +++ b/packages/backend-core/tests/core/utilities/utils/time.ts @@ -0,0 +1,3 @@ +export function addDaysToDate(date: Date, days: number) { + return new Date(date.getTime() + days * 24 * 60 * 60 * 1000) +} diff --git a/packages/bbui/src/InlineAlert/InlineAlert.svelte b/packages/bbui/src/InlineAlert/InlineAlert.svelte index bd873042b3..bfc56818cb 100644 --- a/packages/bbui/src/InlineAlert/InlineAlert.svelte +++ b/packages/bbui/src/InlineAlert/InlineAlert.svelte @@ -7,7 +7,7 @@ export let message = "" export let onConfirm = undefined export let buttonText = "" - + export let cta = false $: icon = selectIcon(type) // if newlines used, convert them to different elements $: split = message.split("\n") @@ -41,7 +41,9 @@ {/each} {#if onConfirm} {/if} @@ -57,7 +59,6 @@ --spectrum-semantic-negative-icon-color: #e34850; min-width: 100px; margin: 0; - border-color: var(--spectrum-global-color-gray-400); border-width: 1px; } diff --git a/packages/bbui/src/Layout/Page.svelte b/packages/bbui/src/Layout/Page.svelte index 01111fda9a..57c264231b 100644 --- a/packages/bbui/src/Layout/Page.svelte +++ b/packages/bbui/src/Layout/Page.svelte @@ -4,6 +4,7 @@ export let wide = false export let narrow = false + export let narrower = false export let noPadding = false let sidePanelVisble = false @@ -16,7 +17,7 @@
-
+
@@ -70,6 +71,9 @@ .content.narrow { max-width: 840px; } + .content.narrower { + max-width: 700px; + } #side-panel { position: absolute; right: 0; diff --git a/packages/bbui/src/Stores/banner.js b/packages/bbui/src/Stores/banner.js index ba6d187d97..1a0b2d9ecc 100644 --- a/packages/bbui/src/Stores/banner.js +++ b/packages/bbui/src/Stores/banner.js @@ -3,6 +3,7 @@ import { writable } from "svelte/store" export const BANNER_TYPES = { INFO: "info", NEGATIVE: "negative", + WARNING: "warning", } export function createBannerStore() { @@ -38,7 +39,8 @@ export function createBannerStore() { const queue = async entries => { const priority = { [BANNER_TYPES.NEGATIVE]: 0, - [BANNER_TYPES.INFO]: 1, + [BANNER_TYPES.WARNING]: 1, + [BANNER_TYPES.INFO]: 2, } banner.update(store => { const sorted = [...store.messages, ...entries].sort((a, b) => { diff --git a/packages/bbui/src/Typography/Detail.svelte b/packages/bbui/src/Typography/Detail.svelte index 76437ffb3c..80e5e1cbe9 100644 --- a/packages/bbui/src/Typography/Detail.svelte +++ b/packages/bbui/src/Typography/Detail.svelte @@ -3,9 +3,13 @@ export let size = "M" export let serif = false + export let weight = 600

@@ -13,7 +17,4 @@

diff --git a/packages/builder/src/components/common/users/UpgradeModal.svelte b/packages/builder/src/components/common/users/UpgradeModal.svelte new file mode 100644 index 0000000000..52d5e1f9e9 --- /dev/null +++ b/packages/builder/src/components/common/users/UpgradeModal.svelte @@ -0,0 +1,23 @@ + + + + isOwner + ? $licensing.goToUpgradePage() + : window.open("https://budibase.com/pricing/", "_blank")} + confirmText={isOwner ? "Upgrade" : "View plans"} + title="Upgrade to add more users" +> +
+ Free plan is limited to {$licensing.license.quotas.usage.static.users.value} + users. Upgrade your plan to add more users. +
+
+ + diff --git a/packages/builder/src/components/portal/licensing/constants.js b/packages/builder/src/components/portal/licensing/constants.js index 57f3a36709..de240adbbe 100644 --- a/packages/builder/src/components/portal/licensing/constants.js +++ b/packages/builder/src/components/portal/licensing/constants.js @@ -7,6 +7,7 @@ export const ExpiringKeys = { LICENSING_ROWS_WARNING_BANNER: "licensing_rows_warning_banner", LICENSING_AUTOMATIONS_WARNING_BANNER: "licensing_automations_warning_banner", LICENSING_QUERIES_WARNING_BANNER: "licensing_queries_warning_banner", + LICENSING_USERS_ABOVE_LIMIT_BANNER: "licensing_users_above_limit_banner", } export const StripeStatus = { diff --git a/packages/builder/src/components/portal/licensing/licensingBanners.js b/packages/builder/src/components/portal/licensing/licensingBanners.js index 34df283b68..41c6585b02 100644 --- a/packages/builder/src/components/portal/licensing/licensingBanners.js +++ b/packages/builder/src/components/portal/licensing/licensingBanners.js @@ -3,6 +3,7 @@ import { temporalStore } from "builderStore" import { admin, auth, licensing } from "stores/portal" import { get } from "svelte/store" import { BANNER_TYPES } from "@budibase/bbui" +import { capitalise } from "helpers" const oneDayInSeconds = 86400 @@ -141,6 +142,30 @@ const buildPaymentFailedBanner = () => { } } +const buildUsersAboveLimitBanner = EXPIRY_KEY => { + const userLicensing = get(licensing) + return { + key: EXPIRY_KEY, + type: BANNER_TYPES.WARNING, + criteria: () => { + return userLicensing.warnUserLimit + }, + message: `${capitalise( + userLicensing.license.plan.type + )} plan changes - Users will be limited to ${ + userLicensing.userLimit + } users in ${userLicensing.userLimitDays}`, + ...{ + extraButtonText: "Find out more", + extraButtonAction: () => { + defaultCacheFn(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER) + window.location.href = "/builder/portal/users/users" + }, + }, + showCloseButton: true, + } +} + export const getBanners = () => { return [ buildPaymentFailedBanner(), @@ -163,6 +188,7 @@ export const getBanners = () => { ExpiringKeys.LICENSING_QUERIES_WARNING_BANNER, 90 ), + buildUsersAboveLimitBanner(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER), ].filter(licensingBanner => { return ( !temporalStore.actions.getExpiring(licensingBanner.key) && diff --git a/packages/builder/src/constants/index.js b/packages/builder/src/constants/index.js index 99c731231d..66fd926a77 100644 --- a/packages/builder/src/constants/index.js +++ b/packages/builder/src/constants/index.js @@ -67,3 +67,8 @@ export const OnboardingType = { EMAIL: "email", PASSWORD: "password", } + +export const PlanModel = { + PER_USER: "perUser", + DAY_PASS: "dayPass", +} diff --git a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte index 33cec5bca1..9a6d9ea1d3 100644 --- a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte @@ -8,14 +8,16 @@ notifications, ActionButton, CopyInput, + Modal, } from "@budibase/bbui" import { store } from "builderStore" - import { groups, licensing, apps, users } from "stores/portal" + import { groups, licensing, apps, users, auth, admin } from "stores/portal" import { fetchData } from "@budibase/frontend-core" import { API } from "api" import { onMount } from "svelte" import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte" import RoleSelect from "components/common/RoleSelect.svelte" + import UpgradeModal from "components/common/users/UpgradeModal.svelte" import { Constants, Utils } from "@budibase/frontend-core" import { emailValidator } from "helpers/validation" import { roles } from "stores/backend" @@ -33,6 +35,8 @@ let selectedGroup let userOnboardResponse = null + let userLimitReachedModal + $: queryIsEmail = emailValidator(query) === true $: prodAppId = apps.getProdAppID($store.appId) $: promptInvite = showInvite( @@ -41,6 +45,7 @@ filteredGroups, query ) + $: isOwner = $auth.accountPortalAccess && $admin.cloud const showInvite = (invites, users, groups, query) => { return !invites?.length && !users?.length && !groups?.length && query @@ -450,7 +455,9 @@ Add user @@ -608,6 +615,9 @@ {/if}
+ + +