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 <rory.codes@gmail.com>
Co-authored-by: mike12345567 <me@michaeldrury.co.uk>

* 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 <rory.codes@gmail.com>

* 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 <mateuspieri@gmail.com>
Co-authored-by: mike12345567 <me@michaeldrury.co.uk>
Co-authored-by: Peter Clement <PClmnt@users.noreply.github.com>
This commit is contained in:
Rory Powell 2023-04-24 09:31:48 +01:00 committed by GitHub
parent c8136c25da
commit ec06f13aa6
56 changed files with 815 additions and 273 deletions

View File

@ -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",

View File

@ -115,10 +115,10 @@ export async function doInContext(appId: string, task: any): Promise<any> {
)
}
export async function doInTenant(
export async function doInTenant<T>(
tenantId: string | null,
task: any
): Promise<any> {
task: () => T
): Promise<T> {
// make sure default always selected in single tenancy
if (!env.MULTI_TENANCY) {
tenantId = tenantId || DEFAULT_TENANT_ID

View File

@ -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<T>(
viewName: ViewName,
params: DatabaseQueryOpts,
db: Database,
createFunc: any,
opts?: QueryViewOptions
): Promise<AllDocsResponse<T>> {
try {
const response = await db.query<T>(`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 <T>(
viewName: ViewName,
params: DatabaseQueryOpts,
@ -126,34 +154,18 @@ export const queryView = async <T>(
createFunc: any,
opts?: QueryViewOptions
): Promise<T[] | T | undefined> => {
try {
let response = await db.query<T>(`database/${viewName}`, params)
const rows = response.rows
const docs = rows.map((row: any) =>
params.include_docs ? row.doc : row.value
)
const response = await queryViewRaw<T>(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 <T>(
})
}
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 <T>(
viewName: ViewName,
params: DatabaseQueryOpts,
db?: Database,
opts?: QueryViewOptions
): Promise<T[] | T | undefined> => {
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 <T>(
const createFn = CreateFuncByName[viewName]
return queryView(viewName, params, db!, createFn, opts)
}
export async function queryGlobalViewRaw<T>(
viewName: ViewName,
params: DatabaseQueryOpts,
opts?: QueryViewOptions
) {
const db = getGlobalDB()
const createFn = CreateFuncByName[viewName]
return queryViewRaw<T>(viewName, params, db, createFn, opts)
}

View File

@ -306,4 +306,5 @@ export default {
identify,
identifyGroup,
getInstallationId,
getUniqueTenantId,
}

View File

@ -33,7 +33,7 @@ async function createInstallDoc(platformDb: Database) {
}
}
const getInstallFromDB = async (): Promise<Installation> => {
export const getInstallFromDB = async (): Promise<Installation> => {
return doWithDB(
StaticDatabases.PLATFORM_INFO.name,
async (platformDb: any) => {

View File

@ -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]

View File

@ -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<Redlock> => {
const getClient = async (
type: LockType,
opts?: Redlock.Options
): Promise<Redlock> => {
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()

View File

@ -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
}

View File

@ -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<App[]>
)
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))
}

View File

@ -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"

View File

@ -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")
})

View File

@ -0,0 +1,20 @@
import Chance from "chance"
export default class CustomChance extends Chance {
arrayOf<T>(
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
}
}

View File

@ -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(),
}
}

View File

@ -1,2 +1,2 @@
import Chance from "chance"
import Chance from "./Chance"
export const generator = new Chance()

View File

@ -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"

View File

@ -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(),
}
}

View File

@ -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: {},
},
}
}

View File

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

View File

@ -0,0 +1,3 @@
export function addDaysToDate(date: Date, days: number) {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000)
}

View File

@ -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}
<div class="spectrum-InLineAlert-footer button">
<Button secondary on:click={onConfirm}>{buttonText || "OK"}</Button>
<Button {cta} secondary={cta ? false : true} on:click={onConfirm}
>{buttonText || "OK"}</Button
>
</div>
{/if}
</div>
@ -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;
}
</style>

View File

@ -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 @@
<div class="page">
<div class="main">
<div class="content" class:wide class:noPadding class:narrow>
<div class="content" class:wide class:noPadding class:narrow class:narrower>
<slot />
<div class="fix-scroll-padding" />
</div>
@ -70,6 +71,9 @@
.content.narrow {
max-width: 840px;
}
.content.narrower {
max-width: 700px;
}
#side-panel {
position: absolute;
right: 0;

View File

@ -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) => {

View File

@ -3,9 +3,13 @@
export let size = "M"
export let serif = false
export let weight = 600
</script>
<p
style={`
${weight ? `font-weight:${weight};` : ""}
`}
class="spectrum-Detail spectrum-Detail--size{size}"
class:spectrum-Detail--serif={serif}
>
@ -13,7 +17,4 @@
</p>
<style>
p {
font-weight: 600;
}
</style>

View File

@ -0,0 +1,23 @@
<script>
import { ModalContent } from "@budibase/bbui"
import { licensing } from "stores/portal"
export let isOwner
</script>
<ModalContent
onConfirm={() =>
isOwner
? $licensing.goToUpgradePage()
: window.open("https://budibase.com/pricing/", "_blank")}
confirmText={isOwner ? "Upgrade" : "View plans"}
title="Upgrade to add more users"
>
<div>
Free plan is limited to {$licensing.license.quotas.usage.static.users.value}
users. Upgrade your plan to add more users.
</div>
</ModalContent>
<style>
</style>

View File

@ -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 = {

View File

@ -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) &&

View File

@ -67,3 +67,8 @@ export const OnboardingType = {
EMAIL: "email",
PASSWORD: "password",
}
export const PlanModel = {
PER_USER: "perUser",
DAY_PASS: "dayPass",
}

View File

@ -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 @@
<ActionButton
icon="UserAdd"
disabled={!queryIsEmail || inviting}
on:click={onInviteUser}
on:click={$licensing.userLimitReached
? userLimitReachedModal.show
: onInviteUser}
>
Add user
</ActionButton>
@ -608,6 +615,9 @@
</Layout>
{/if}
</div>
<Modal bind:this={userLimitReachedModal}>
<UpgradeModal {isOwner} />
</Modal>
</div>
<style>

View File

@ -128,7 +128,7 @@
/>
</div>
</div>
<ButtonGroup>
<ButtonGroup gap="M">
<Button cta on:click={activate} disabled={activateDisabled}>
Activate
</Button>

View File

@ -13,6 +13,7 @@
import { admin, auth, licensing } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
import { DashCard, Usage } from "components/usage"
import { PlanModel } from "constants"
let staticUsage = []
let monthlyUsage = []
@ -25,8 +26,21 @@
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
const manageUrl = `${$admin.accountPortalUrl}/portal/billing`
const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes"]
const EXCLUDE_QUOTAS = ["Queries"]
const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes", "Users"]
const EXCLUDE_QUOTAS = {
Queries: () => true,
Users: license => {
return license.plan.model !== PlanModel.PER_USER
},
"Day Passes": license => {
return license.plan.model !== PlanModel.DAY_PASS
},
}
function excludeQuota(name) {
return EXCLUDE_QUOTAS[name] && EXCLUDE_QUOTAS[name](license)
}
$: quotaUsage = $licensing.quotaUsage
$: license = $auth.user?.license
@ -39,7 +53,7 @@
monthlyUsage = []
if (quotaUsage.monthly) {
for (let [key, value] of Object.entries(license.quotas.usage.monthly)) {
if (EXCLUDE_QUOTAS.includes(value.name)) {
if (excludeQuota(value.name)) {
continue
}
const used = quotaUsage.monthly.current[key]
@ -58,7 +72,7 @@
const setStaticUsage = () => {
staticUsage = []
for (let [key, value] of Object.entries(license.quotas.usage.static)) {
if (EXCLUDE_QUOTAS.includes(value.name)) {
if (excludeQuota(value.name)) {
continue
}
const used = quotaUsage.usageQuota[key]
@ -84,7 +98,7 @@
}
const planTitle = () => {
return capitalise(license?.plan.type)
return `${capitalise(license?.plan.type)} Plan`
}
const getDaysRemaining = timestamp => {
@ -110,8 +124,8 @@
if (cancelAt) {
textRows.push({ message: "Subscription has been cancelled" })
textRows.push({
message: `${getDaysRemaining(cancelAt * 1000)} days remaining`,
tooltip: new Date(cancelAt * 1000),
message: `${getDaysRemaining(cancelAt)} days remaining`,
tooltip: new Date(cancelAt),
})
}
}

View File

@ -11,6 +11,7 @@
import { groups, licensing } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
import { emailValidator } from "helpers/validation"
import { capitalise } from "helpers"
export let showOnboardingTypeModal
@ -28,6 +29,10 @@
]
$: hasError = userData.find(x => x.error != null)
$: userCount = $licensing.userCount + userData.length
$: willReach = licensing.willReachUserLimit(userCount)
$: willExceed = licensing.willExceedUserLimit(userCount)
function removeInput(idx) {
userData = userData.filter((e, i) => i !== idx)
}
@ -82,7 +87,7 @@
confirmDisabled={disabled}
cancelText="Cancel"
showCloseIcon={false}
disabled={hasError || !userData.length}
disabled={hasError || !userData.length || willExceed}
>
<Layout noPadding gap="XS">
<Label>Email address</Label>
@ -112,9 +117,20 @@
</div>
</div>
{/each}
<div>
<ActionButton on:click={addNewInput} icon="Add">Add email</ActionButton>
</div>
{#if willReach}
<div class="user-notification">
<Icon name="Info" />
<span>
{capitalise($licensing.license.plan.type)} plan is limited to {$licensing.userLimit}
users. Upgrade your plan to add more users</span
>
</div>
{:else}
<div>
<ActionButton on:click={addNewInput} icon="Add">Add email</ActionButton>
</div>
{/if}
</Layout>
{#if $licensing.groupsEnabled}
@ -130,6 +146,12 @@
</ModalContent>
<style>
.user-notification {
display: flex;
align-items: center;
flex-direction: row;
gap: var(--spacing-m);
}
.icon {
width: 10%;
align-self: flex-start;

View File

@ -5,10 +5,12 @@
RadioGroup,
Multiselect,
notifications,
Icon,
} from "@budibase/bbui"
import { groups, licensing, admin } from "stores/portal"
import { emailValidator } from "helpers/validation"
import { Constants } from "@budibase/frontend-core"
import { capitalise } from "helpers"
const BYTES_IN_MB = 1000000
const FILE_SIZE_LIMIT = BYTES_IN_MB * 5
@ -20,9 +22,14 @@
let userEmails = []
let userGroups = []
let usersRole = null
$: invalidEmails = []
$: userCount = $licensing.userCount + userEmails.length
$: willExceed = userCount > $licensing.userLimit
$: importDisabled =
!userEmails.length || !validEmails(userEmails) || !usersRole || willExceed
const validEmails = userEmails => {
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) {
notifications.error(
@ -75,7 +82,7 @@
cancelText="Cancel"
showCloseIcon={false}
onConfirm={() => createUsersFromCsv({ userEmails, usersRole, userGroups })}
disabled={!userEmails.length || !validEmails(userEmails) || !usersRole}
disabled={importDisabled}
>
<Body size="S">Import your users email addresses from a CSV file</Body>
@ -86,6 +93,13 @@
</label>
</div>
{#if willExceed}
<div class="user-notification">
<Icon name="Info" />
{capitalise($licensing.license.plan.type)} plan is limited to {$licensing.userLimit}
users. Upgrade your plan to add more users
</div>
{/if}
<RadioGroup
bind:value={usersRole}
options={Constants.BuilderRoleDescriptions}
@ -104,6 +118,13 @@
</ModalContent>
<style>
.user-notification {
display: flex;
align-items: center;
flex-direction: row;
gap: var(--spacing-m);
}
.dropzone {
text-align: center;
display: flex;

View File

@ -11,6 +11,7 @@
notifications,
Pagination,
Divider,
InlineAlert,
} from "@budibase/bbui"
import AddUserModal from "./_components/AddUserModal.svelte"
import {
@ -20,9 +21,11 @@
licensing,
organisation,
features,
admin,
} from "stores/portal"
import { onMount } from "svelte"
import DeleteRowsButton from "components/backend/DataTable/buttons/DeleteRowsButton.svelte"
import UpgradeModal from "components/common/users/UpgradeModal.svelte"
import GroupsTableRenderer from "./_components/GroupsTableRenderer.svelte"
import AppsTableRenderer from "./_components/AppsTableRenderer.svelte"
import RoleTableRenderer from "./_components/RoleTableRenderer.svelte"
@ -50,7 +53,8 @@
inviteConfirmationModal,
onboardingTypeModal,
passwordModal,
importUsersModal
importUsersModal,
userLimitReachedModal
let searchEmail = undefined
let selectedRows = []
let bulkSaveResponse
@ -61,7 +65,9 @@
]
let userData = []
$: isOwner = $auth.accountPortalAccess && $admin.cloud
$: readonly = !$auth.isAdmin || $features.isScimEnabled
$: debouncedUpdateFetch(searchEmail)
$: schema = {
email: {
@ -81,6 +87,7 @@
width: "1fr",
},
}
$: userData = []
$: inviteUsersResponse = { successful: [], unsuccessful: [] }
$: {
@ -229,6 +236,8 @@
notifications.error("Error fetching user group data")
}
})
let staticUserLimit = $licensing.license.quotas.usage.static.users.value
</script>
<Layout noPadding gap="M">
@ -237,13 +246,46 @@
<Body>Add users and control who gets access to your published apps</Body>
</Layout>
<Divider />
{#if $licensing.warnUserLimit}
<InlineAlert
type="error"
onConfirm={() => {
if (isOwner) {
$licensing.goToUpgradePage()
} else {
window.open("https://budibase.com/pricing/", "_blank")
}
}}
buttonText={isOwner ? "Upgrade" : "View plans"}
cta
header={`Users will soon be limited to ${staticUserLimit}`}
message={`Our free plan is going to be limited to ${staticUserLimit} users in ${$licensing.userLimitDays}.
This means any users exceeding the limit have been de-activated.
De-activated users will not able to access the builder or any published apps until you upgrade to one of our paid plans.
`}
/>
{/if}
<div class="controls">
{#if !readonly}
<ButtonGroup>
<Button disabled={readonly} on:click={createUserModal.show} cta>
<Button
disabled={readonly}
on:click={$licensing.userLimitReached
? userLimitReachedModal.show
: createUserModal.show}
cta
>
Add users
</Button>
<Button disabled={readonly} on:click={importUsersModal.show} secondary>
<Button
disabled={readonly}
on:click={$licensing.userLimitReached
? userLimitReachedModal.show
: importUsersModal.show}
secondary
>
Import
</Button>
</ButtonGroup>
@ -307,6 +349,10 @@
<ImportUsersModal {createUsersFromCsv} />
</Modal>
<Modal bind:this={userLimitReachedModal}>
<UpgradeModal {isOwner} />
</Modal>
<style>
.pagination {
display: flex;
@ -319,7 +365,6 @@
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-xl);
}

View File

@ -4,6 +4,9 @@ import { auth, admin } from "stores/portal"
import { Constants } from "@budibase/frontend-core"
import { StripeStatus } from "components/portal/licensing/constants"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
import dayjs from "dayjs"
const UNLIMITED = -1
export const createLicensingStore = () => {
const DEFAULT = {
@ -31,17 +34,37 @@ export const createLicensingStore = () => {
pastDueEndDate: undefined,
pastDueDaysRemaining: undefined,
accountDowngraded: undefined,
// user limits
userCount: undefined,
userLimit: undefined,
userLimitDays: undefined,
userLimitReached: false,
warnUserLimit: false,
}
const oneDayInMilliseconds = 86400000
const store = writable(DEFAULT)
function willReachUserLimit(userCount, userLimit) {
if (userLimit === UNLIMITED) {
return false
}
return userCount >= userLimit
}
function willExceedUserLimit(userCount, userLimit) {
if (userLimit === UNLIMITED) {
return false
}
return userCount > userLimit
}
const actions = {
init: async () => {
actions.setNavigation()
actions.setLicense()
await actions.setQuotaUsage()
actions.setUsageMetrics()
},
setNavigation: () => {
const upgradeUrl = `${get(admin).accountPortalUrl}/portal/upgrade`
@ -105,10 +128,17 @@ export const createLicensingStore = () => {
quotaUsage,
}
})
actions.setUsageMetrics()
},
willReachUserLimit: userCount => {
return willReachUserLimit(userCount, get(store).userLimit)
},
willExceedUserLimit(userCount) {
return willExceedUserLimit(userCount, get(store).userLimit)
},
setUsageMetrics: () => {
if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) {
const quota = get(store).quotaUsage
const usage = get(store).quotaUsage
const license = get(auth).user.license
const now = new Date()
@ -126,12 +156,12 @@ export const createLicensingStore = () => {
const monthlyMetrics = getMetrics(
["dayPasses", "queries", "automations"],
license.quotas.usage.monthly,
quota.monthly.current
usage.monthly.current
)
const staticMetrics = getMetrics(
["apps", "rows"],
license.quotas.usage.static,
quota.usageQuota
usage.usageQuota
)
const getDaysBetween = (dateStart, dateEnd) => {
@ -142,7 +172,7 @@ export const createLicensingStore = () => {
: 0
}
const quotaResetDate = new Date(quota.quotaReset)
const quotaResetDate = new Date(usage.quotaReset)
const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate)
const accountDowngraded =
@ -165,6 +195,15 @@ export const createLicensingStore = () => {
)
}
const userQuota = license.quotas.usage.static.users
const userLimit = userQuota?.value
const userCount = usage.usageQuota.users
const userLimitReached = willReachUserLimit(userCount, userLimit)
const userLimitExceeded = willExceedUserLimit(userCount, userLimit)
const days = dayjs(userQuota?.startDate).diff(dayjs(), "day")
const userLimitDays = days > 1 ? `${days} days` : "1 day"
const warnUserLimit = userQuota?.startDate && userLimitExceeded
store.update(state => {
return {
...state,
@ -175,6 +214,12 @@ export const createLicensingStore = () => {
accountPastDue: pastDueAtMilliseconds != null,
pastDueEndDate,
pastDueDaysRemaining,
// user limits
userCount,
userLimit,
userLimitDays,
userLimitReached,
warnUserLimit,
}
})
}

View File

@ -1,6 +1,7 @@
import { writable } from "svelte/store"
import { API } from "api"
import { update } from "lodash"
import { licensing } from "."
export function createUsersStore() {
const { subscribe, set } = writable({})
@ -113,6 +114,12 @@ export function createUsersStore() {
const getUserRole = ({ admin, builder }) =>
admin?.global ? "admin" : builder?.global ? "developer" : "appUser"
const refreshUsage = fn => async args => {
const response = await fn(args)
await licensing.setQuotaUsage()
return response
}
return {
subscribe,
search,
@ -121,15 +128,16 @@ export function createUsersStore() {
fetch,
invite,
onboard,
acceptInvite,
fetchInvite,
getInvites,
updateInvite,
create,
save,
bulkDelete,
getUserCountByApp,
delete: del,
// any operation that adds or deletes users
acceptInvite: refreshUsage(acceptInvite),
create: refreshUsage(create),
save: refreshUsage(save),
bulkDelete: refreshUsage(bulkDelete),
delete: refreshUsage(del),
}
}

View File

@ -2,6 +2,7 @@ import { runQuotaMigration } from "./usageQuotas"
import * as syncApps from "./usageQuotas/syncApps"
import * as syncRows from "./usageQuotas/syncRows"
import * as syncPlugins from "./usageQuotas/syncPlugins"
import * as syncUsers from "./usageQuotas/syncUsers"
/**
* Synchronise quotas to the state of the db.
@ -11,5 +12,6 @@ export const run = async () => {
await syncApps.run()
await syncRows.run()
await syncPlugins.run()
await syncUsers.run()
})
}

View File

@ -1,26 +0,0 @@
const syncApps = jest.fn()
const syncRows = jest.fn()
const syncPlugins = jest.fn()
jest.mock("../usageQuotas/syncApps", () => ({ run: syncApps }) )
jest.mock("../usageQuotas/syncRows", () => ({ run: syncRows }) )
jest.mock("../usageQuotas/syncPlugins", () => ({ run: syncPlugins }) )
const TestConfig = require("../../../tests/utilities/TestConfiguration")
const migration = require("../syncQuotas")
describe("run", () => {
let config = new TestConfig(false)
beforeAll(async () => {
await config.init()
})
afterAll(config.end)
it("run", async () => {
await migration.run()
expect(syncApps).toHaveBeenCalledTimes(1)
expect(syncRows).toHaveBeenCalledTimes(1)
expect(syncPlugins).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,4 +1,4 @@
import { tenancy, db as dbCore } from "@budibase/backend-core"
import { db as dbCore } from "@budibase/backend-core"
import { quotas } from "@budibase/pro"
import { QuotaUsageType, StaticQuotaName } from "@budibase/types"
@ -8,7 +8,6 @@ export const run = async () => {
const appCount = devApps ? devApps.length : 0
// sync app count
const tenantId = tenancy.getTenantId()
console.log(`Syncing app count: ${appCount}`)
await quotas.setUsage(appCount, StaticQuotaName.APPS, QuotaUsageType.STATIC)
}

View File

@ -0,0 +1,9 @@
import { users } from "@budibase/backend-core"
import { quotas } from "@budibase/pro"
import { QuotaUsageType, StaticQuotaName } from "@budibase/types"
export const run = async () => {
const userCount = await users.getUserCount()
console.log(`Syncing user count: ${userCount}`)
await quotas.setUsage(userCount, StaticQuotaName.USERS, QuotaUsageType.STATIC)
}

View File

@ -0,0 +1,26 @@
import TestConfig from "../../../../tests/utilities/TestConfiguration"
import * as syncUsers from "../syncUsers"
import { quotas } from "@budibase/pro"
describe("syncUsers", () => {
let config = new TestConfig(false)
beforeEach(async () => {
await config.init()
})
afterAll(config.end)
it("syncs users", async () => {
return config.doInContext(null, async () => {
await config.createUser()
await syncUsers.run()
const usageDoc = await quotas.getQuotaUsage()
// default + additional user
const userCount = 2
expect(usageDoc.usageQuota.users).toBe(userCount)
})
})
})

View File

@ -11,6 +11,7 @@ import env from "../environment"
// migration functions
import * as userEmailViewCasing from "./functions/userEmailViewCasing"
import * as syncQuotas from "./functions/syncQuotas"
import * as syncUsers from "./functions/usageQuotas/syncUsers"
import * as appUrls from "./functions/appUrls"
import * as tableSettings from "./functions/tableSettings"
import * as backfill from "./functions/backfill"

View File

@ -25,6 +25,7 @@
"@types/koa": "2.13.4",
"@types/node": "14.18.20",
"@types/pouchdb": "6.4.0",
"@types/redlock": "4.0.3",
"concurrently": "^7.6.0",
"koa-body": "4.2.0",
"rimraf": "3.0.2",

View File

@ -1,7 +1,14 @@
import { QuotaUsage } from "../../documents"
export interface GetLicenseRequest {
quotaUsage: QuotaUsage
// All fields should be optional to cater for
// historical versions of budibase
quotaUsage?: QuotaUsage
install: {
id: string
tenantId: string
version: string
}
}
export interface QuotaTriggeredRequest {
@ -9,3 +16,7 @@ export interface QuotaTriggeredRequest {
name: string
resetDate?: string
}
export interface LicenseActivateRequest {
installVersion?: string
}

View File

@ -22,15 +22,19 @@ export interface BulkUserRequest {
}
}
export interface BulkUserCreated {
successful: UserDetails[]
unsuccessful: { email: string; reason: string }[]
}
export interface BulkUserDeleted {
successful: UserDetails[]
unsuccessful: { _id: string; email: string; reason: string }[]
}
export interface BulkUserResponse {
created?: {
successful: UserDetails[]
unsuccessful: { email: string; reason: string }[]
}
deleted?: {
successful: UserDetails[]
unsuccessful: { _id: string; email: string; reason: string }[]
}
created?: BulkUserCreated
deleted?: BulkUserDeleted
message?: string
}

View File

@ -1,14 +1,5 @@
import {
Feature,
Hosting,
License,
MonthlyQuotaName,
PlanType,
PriceDuration,
Quotas,
StaticQuotaName,
} from "../../sdk"
import { MonthlyUsage, QuotaUsage, StaticUsage } from "../global"
import { Feature, Hosting, License, PlanType, Quotas } from "../../sdk"
import { QuotaUsage } from "../global"
export interface CreateAccount {
email: string
@ -49,6 +40,9 @@ export interface Account extends CreateAccount {
planType?: PlanType
planTier?: number
license?: License
installId?: string
installTenantId?: string
installVersion?: string
stripeCustomerId?: string
licenseKey?: string
licenseKeyActivatedAt?: number

View File

@ -31,6 +31,7 @@ export type QuotaTriggers = {
export interface StaticUsage {
[StaticQuotaName.APPS]: number
[StaticQuotaName.PLUGINS]: number
[StaticQuotaName.USERS]: number
[StaticQuotaName.USER_GROUPS]: number
[StaticQuotaName.ROWS]: number
triggers: {

View File

@ -7,6 +7,7 @@ export interface Customer {
export interface Subscription {
amount: number
currency: string
quantity: number
duration: PriceDuration
cancelAt: number | null | undefined

View File

@ -1,3 +1,5 @@
import { PlanType } from "./plan"
export enum Feature {
USER_GROUPS = "userGroups",
APP_BACKUPS = "appBackups",
@ -7,3 +9,5 @@ export enum Feature {
BRANDING = "branding",
SCIM = "scim",
}
export type PlanFeatures = { [key in PlanType]: Feature[] | undefined }

View File

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

View File

@ -1,12 +1,10 @@
export interface AccountPlan {
type: PlanType
price?: Price
}
export enum PlanType {
FREE = "free",
/** @deprecated */
PRO = "pro",
/** @deprecated */
TEAM = "team",
PREMIUM = "premium",
BUSINESS = "business",
ENTERPRISE = "enterprise",
}
@ -16,12 +14,36 @@ export enum PriceDuration {
YEARLY = "yearly",
}
export interface Price {
export interface AvailablePlan {
type: PlanType
maxUsers: number
minUsers: number
prices: AvailablePrice[]
}
export interface AvailablePrice {
amount: number
amountMonthly: number
currency: string
duration: PriceDuration
priceId: string
dayPasses: number
}
export enum PlanModel {
PER_USER = "perUser",
DAY_PASS = "dayPass",
}
export interface PurchasedPlan {
type: PlanType
model: PlanModel
usesInvoicing: boolean
minUsers: number
price?: PurchasedPrice
}
export interface PurchasedPrice extends AvailablePrice {
dayPasses: number | undefined
/** @deprecated - now at the plan level via model */
isPerUser: boolean
}

View File

@ -13,6 +13,7 @@ export enum QuotaType {
export enum StaticQuotaName {
ROWS = "rows",
APPS = "apps",
USERS = "users",
USER_GROUPS = "userGroups",
PLUGINS = "plugins",
}
@ -54,14 +55,14 @@ export const isConstantQuota = (
return quotaType === QuotaType.CONSTANT
}
export type PlanQuotas = {
[PlanType.FREE]: Quotas
[PlanType.PRO]: Quotas
[PlanType.TEAM]: Quotas
[PlanType.BUSINESS]: Quotas
[PlanType.ENTERPRISE]: Quotas
export interface Minimums {
users: number
}
export type PlanMinimums = { [key in PlanType]: Minimums }
export type PlanQuotas = { [key in PlanType]: Quotas | undefined }
export type MonthlyQuotas = {
[MonthlyQuotaName.QUERIES]: Quota
[MonthlyQuotaName.AUTOMATIONS]: Quota
@ -71,6 +72,7 @@ export type MonthlyQuotas = {
export type StaticQuotas = {
[StaticQuotaName.ROWS]: Quota
[StaticQuotaName.APPS]: Quota
[StaticQuotaName.USERS]: Quota
[StaticQuotaName.USER_GROUPS]: Quota
[StaticQuotaName.PLUGINS]: Quota
}
@ -99,4 +101,5 @@ export interface Quota {
* which can have subsequent effects such as sending emails to users.
*/
triggers: number[]
startDate?: number
}

View File

@ -1,3 +1,5 @@
import Redlock from "redlock"
export enum LockType {
/**
* If this lock is already held the attempted operation will not be performed.
@ -6,6 +8,7 @@ export enum LockType {
TRY_ONCE = "try_once",
DEFAULT = "default",
DELAY_500 = "delay_500",
CUSTOM = "custom",
}
export enum LockName {
@ -14,6 +17,7 @@ export enum LockName {
SYNC_ACCOUNT_LICENSE = "sync_account_license",
UPDATE_TENANTS_DOC = "update_tenants_doc",
PERSIST_WRITETHROUGH = "persist_writethrough",
QUOTA_USAGE_EVENT = "quota_usage_event",
}
export interface LockOptions {
@ -21,6 +25,11 @@ export interface LockOptions {
* The lock type determines which client to use
*/
type: LockType
/**
* The custom options to use when creating the redlock instance
* type must be set to custom for the options to be applied
*/
customOptions?: Redlock.Options
/**
* The name for the lock
*/

View File

@ -329,6 +329,7 @@ export const checkInvite = async (ctx: any) => {
try {
invite = await checkInviteCode(code, false)
} catch (e) {
console.warn("Error getting invite from code", e)
ctx.throw(400, "There was a problem with the invite")
}
ctx.body = {
@ -415,8 +416,8 @@ export const inviteAccept = async (
})
ctx.body = {
_id: user._id,
_rev: user._rev,
_id: user._id!,
_rev: user._rev!,
email: user.email,
}
} catch (err: any) {

View File

@ -20,7 +20,6 @@ import {
import {
AccountMetadata,
AllDocsResponse,
BulkUserResponse,
CloudAccount,
InviteUsersRequest,
InviteUsersResponse,
@ -31,6 +30,8 @@ import {
RowResponse,
User,
SaveUserOpts,
BulkUserCreated,
BulkUserDeleted,
Account,
} from "@budibase/types"
import { sendEmail } from "../../utilities/email"
@ -196,6 +197,8 @@ export async function isPreventPasswordActions(user: User, account?: Account) {
return !!(account && account.email === user.email && isSSOAccount(account))
}
// TODO: The single save should re-use the bulk insert with a single
// user so that we don't need to duplicate logic
export const save = async (
user: User,
opts: SaveUserOpts = {}
@ -242,53 +245,56 @@ export const save = async (
}
}
await validateUniqueUser(email, tenantId)
const change = dbUser ? 0 : 1 // no change if there is existing user
return pro.quotas.addUsers(change, async () => {
await validateUniqueUser(email, tenantId)
let builtUser = await buildUser(user, opts, tenantId, dbUser)
// don't allow a user to update its own roles/perms
if (opts.currentUserId && opts.currentUserId === dbUser?._id) {
builtUser.builder = dbUser.builder
builtUser.admin = dbUser.admin
builtUser.roles = dbUser.roles
}
let builtUser = await buildUser(user, opts, tenantId, dbUser)
// don't allow a user to update its own roles/perms
if (opts.currentUserId && opts.currentUserId === dbUser?._id) {
builtUser.builder = dbUser.builder
builtUser.admin = dbUser.admin
builtUser.roles = dbUser.roles
}
if (!dbUser && roles?.length) {
builtUser.roles = { ...roles }
}
if (!dbUser && roles?.length) {
builtUser.roles = { ...roles }
}
// make sure we set the _id field for a new user
// Also if this is a new user, associate groups with them
let groupPromises = []
if (!_id) {
_id = builtUser._id!
// make sure we set the _id field for a new user
// Also if this is a new user, associate groups with them
let groupPromises = []
if (!_id) {
_id = builtUser._id!
if (userGroups.length > 0) {
for (let groupId of userGroups) {
groupPromises.push(pro.groups.addUsers(groupId, [_id]))
if (userGroups.length > 0) {
for (let groupId of userGroups) {
groupPromises.push(pro.groups.addUsers(groupId, [_id]))
}
}
}
}
try {
// save the user to db
let response = await db.put(builtUser)
builtUser._rev = response.rev
try {
// save the user to db
let response = await db.put(builtUser)
builtUser._rev = response.rev
await eventHelpers.handleSaveEvents(builtUser, dbUser)
await platform.users.addUser(tenantId, builtUser._id!, builtUser.email)
await cache.user.invalidateUser(response.id)
await eventHelpers.handleSaveEvents(builtUser, dbUser)
await platform.users.addUser(tenantId, builtUser._id!, builtUser.email)
await cache.user.invalidateUser(response.id)
await Promise.all(groupPromises)
await Promise.all(groupPromises)
// finally returned the saved user from the db
return db.get(builtUser._id!)
} catch (err: any) {
if (err.status === 409) {
throw "User exists already"
} else {
throw err
// finally returned the saved user from the db
return db.get(builtUser._id!)
} catch (err: any) {
if (err.status === 409) {
throw "User exists already"
} else {
throw err
}
}
}
})
}
const getExistingTenantUsers = async (emails: string[]): Promise<User[]> => {
@ -374,7 +380,7 @@ const searchExistingEmails = async (emails: string[]) => {
export const bulkCreate = async (
newUsersRequested: User[],
groups: string[]
): Promise<BulkUserResponse["created"]> => {
): Promise<BulkUserCreated> => {
const tenantId = tenancy.getTenantId()
let usersToSave: any[] = []
@ -402,54 +408,56 @@ export const bulkCreate = async (
}
const account = await accountSdk.api.getAccountByTenantId(tenantId)
// create the promises array that will be called by bulkDocs
newUsers.forEach((user: any) => {
usersToSave.push(
buildUser(
user,
{
hashPassword: true,
requirePassword: user.requirePassword,
},
tenantId,
undefined, // no dbUser
account
return pro.quotas.addUsers(newUsers.length, async () => {
// create the promises array that will be called by bulkDocs
newUsers.forEach((user: any) => {
usersToSave.push(
buildUser(
user,
{
hashPassword: true,
requirePassword: user.requirePassword,
},
tenantId,
undefined, // no dbUser
account
)
)
)
})
})
const usersToBulkSave = await Promise.all(usersToSave)
await usersCore.bulkUpdateGlobalUsers(usersToBulkSave)
const usersToBulkSave = await Promise.all(usersToSave)
await usersCore.bulkUpdateGlobalUsers(usersToBulkSave)
// Post-processing of bulk added users, e.g. 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 platform.users.addUser(tenantId, user._id, user.email)
await eventHelpers.handleSaveEvents(user, undefined)
}
// Post-processing of bulk added users, e.g. 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 platform.users.addUser(tenantId, user._id, user.email)
await eventHelpers.handleSaveEvents(user, undefined)
}
const saved = usersToBulkSave.map(user => {
return {
_id: user._id,
email: user.email,
}
})
// now update the groups
if (Array.isArray(saved) && groups) {
const groupPromises = []
const createdUserIds = saved.map(user => user._id)
for (let groupId of groups) {
groupPromises.push(pro.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
}
const saved = usersToBulkSave.map(user => {
return {
_id: user._id,
email: user.email,
successful: saved,
unsuccessful,
}
})
// now update the groups
if (Array.isArray(saved) && groups) {
const groupPromises = []
const createdUserIds = saved.map(user => user._id)
for (let groupId of groups) {
groupPromises.push(pro.groups.addUsers(groupId, createdUserIds))
}
await Promise.all(groupPromises)
}
return {
successful: saved,
unsuccessful,
}
}
/**
@ -474,10 +482,10 @@ const getAccountHolderFromUserIds = async (
export const bulkDelete = async (
userIds: string[]
): Promise<BulkUserResponse["deleted"]> => {
): Promise<BulkUserDeleted> => {
const db = tenancy.getGlobalDB()
const response: BulkUserResponse["deleted"] = {
const response: BulkUserDeleted = {
successful: [],
unsuccessful: [],
}
@ -511,6 +519,8 @@ export const bulkDelete = async (
_deleted: true,
}))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
await pro.quotas.removeUsers(toDelete.length)
for (let user of usersToDelete) {
await bulkDeleteProcessing(user)
}
@ -540,6 +550,8 @@ export const bulkDelete = async (
return response
}
// TODO: The single delete should re-use the bulk delete with a single
// user so that we don't need to duplicate logic
export const destroy = async (id: string) => {
const db = tenancy.getGlobalDB()
const dbUser = (await db.get(id)) as User
@ -562,6 +574,7 @@ export const destroy = async (id: string) => {
await db.remove(userId, dbUser._rev)
await pro.quotas.removeUsers(1)
await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" })

View File

@ -62,8 +62,8 @@ function createSMTPTransport(config?: SMTPInnerConfig) {
host: "smtp.ethereal.email",
secure: false,
auth: {
user: "wyatt.zulauf29@ethereal.email",
pass: "tEwDtHBWWxusVWAPfa",
user: "seamus99@ethereal.email",
pass: "5ghVteZAqj6jkKJF9R",
},
}
}

View File

@ -88,6 +88,6 @@ if [ -d "../account-portal" ]; then
echo "Linking bbui to account-portal"
yarn link "@budibase/bbui"
echo "Linking frontend-core to account-portal"
yarn link "@budibase/frontend-core"
echo "Linking frontend-core to account-portal"
yarn link "@budibase/frontend-core"
fi