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:
parent
c8136c25da
commit
ec06f13aa6
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -306,4 +306,5 @@ export default {
|
|||
identify,
|
||||
identifyGroup,
|
||||
getInstallationId,
|
||||
getUniqueTenantId,
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
import Chance from "chance"
|
||||
import Chance from "./Chance"
|
||||
export const generator = new Chance()
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: {},
|
||||
},
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * as time from "./time"
|
|
@ -0,0 +1,3 @@
|
|||
export function addDaysToDate(date: Date, days: number) {
|
||||
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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 = {
|
||||
|
|
|
@ -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) &&
|
||||
|
|
|
@ -67,3 +67,8 @@ export const OnboardingType = {
|
|||
EMAIL: "email",
|
||||
PASSWORD: "password",
|
||||
}
|
||||
|
||||
export const PlanModel = {
|
||||
PER_USER: "perUser",
|
||||
DAY_PASS: "dayPass",
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -128,7 +128,7 @@
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonGroup>
|
||||
<ButtonGroup gap="M">
|
||||
<Button cta on:click={activate} disabled={activateDisabled}>
|
||||
Activate
|
||||
</Button>
|
||||
|
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -7,6 +7,7 @@ export interface Customer {
|
|||
|
||||
export interface Subscription {
|
||||
amount: number
|
||||
currency: string
|
||||
quantity: number
|
||||
duration: PriceDuration
|
||||
cancelAt: number | null | undefined
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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" })
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue