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_APP = "by_app",
|
||||||
USER_BY_EMAIL = "by_email2",
|
USER_BY_EMAIL = "by_email2",
|
||||||
BY_API_KEY = "by_api_key",
|
BY_API_KEY = "by_api_key",
|
||||||
|
/** @deprecated - could be deleted */
|
||||||
USER_BY_BUILDERS = "by_builders",
|
USER_BY_BUILDERS = "by_builders",
|
||||||
LINK = "by_link",
|
LINK = "by_link",
|
||||||
ROUTING = "screen_routes",
|
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,
|
tenantId: string | null,
|
||||||
task: any
|
task: () => T
|
||||||
): Promise<any> {
|
): Promise<T> {
|
||||||
// make sure default always selected in single tenancy
|
// make sure default always selected in single tenancy
|
||||||
if (!env.MULTI_TENANCY) {
|
if (!env.MULTI_TENANCY) {
|
||||||
tenantId = tenantId || DEFAULT_TENANT_ID
|
tenantId = tenantId || DEFAULT_TENANT_ID
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
} from "../constants"
|
} from "../constants"
|
||||||
import { getGlobalDB } from "../context"
|
import { getGlobalDB } from "../context"
|
||||||
import { doWithDB } from "./"
|
import { doWithDB } from "./"
|
||||||
import { Database, DatabaseQueryOpts } from "@budibase/types"
|
import { AllDocsResponse, Database, DatabaseQueryOpts } from "@budibase/types"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
|
|
||||||
const DESIGN_DB = "_design/database"
|
const DESIGN_DB = "_design/database"
|
||||||
|
@ -119,6 +119,34 @@ export interface QueryViewOptions {
|
||||||
arrayResponse?: boolean
|
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>(
|
export const queryView = async <T>(
|
||||||
viewName: ViewName,
|
viewName: ViewName,
|
||||||
params: DatabaseQueryOpts,
|
params: DatabaseQueryOpts,
|
||||||
|
@ -126,34 +154,18 @@ export const queryView = async <T>(
|
||||||
createFunc: any,
|
createFunc: any,
|
||||||
opts?: QueryViewOptions
|
opts?: QueryViewOptions
|
||||||
): Promise<T[] | T | undefined> => {
|
): Promise<T[] | T | undefined> => {
|
||||||
try {
|
const response = await queryViewRaw<T>(viewName, params, db, createFunc, opts)
|
||||||
let response = await db.query<T>(`database/${viewName}`, params)
|
const rows = response.rows
|
||||||
const rows = response.rows
|
const docs = rows.map((row: any) =>
|
||||||
const docs = rows.map((row: any) =>
|
params.include_docs ? row.doc : row.value
|
||||||
params.include_docs ? row.doc : row.value
|
)
|
||||||
)
|
|
||||||
|
|
||||||
// if arrayResponse has been requested, always return array regardless of length
|
// if arrayResponse has been requested, always return array regardless of length
|
||||||
if (opts?.arrayResponse) {
|
if (opts?.arrayResponse) {
|
||||||
return docs as T[]
|
return docs as T[]
|
||||||
} else {
|
} else {
|
||||||
// return the single document if there is only one
|
// return the single document if there is only one
|
||||||
return docs.length <= 1 ? (docs[0] as T) : (docs as T[])
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>(
|
export const queryGlobalView = async <T>(
|
||||||
viewName: ViewName,
|
viewName: ViewName,
|
||||||
params: DatabaseQueryOpts,
|
params: DatabaseQueryOpts,
|
||||||
db?: Database,
|
db?: Database,
|
||||||
opts?: QueryViewOptions
|
opts?: QueryViewOptions
|
||||||
): Promise<T[] | T | undefined> => {
|
): 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
|
// can pass DB in if working with something specific
|
||||||
if (!db) {
|
if (!db) {
|
||||||
db = getGlobalDB()
|
db = getGlobalDB()
|
||||||
|
@ -227,3 +240,13 @@ export const queryGlobalView = async <T>(
|
||||||
const createFn = CreateFuncByName[viewName]
|
const createFn = CreateFuncByName[viewName]
|
||||||
return queryView(viewName, params, db!, createFn, opts)
|
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,
|
identify,
|
||||||
identifyGroup,
|
identifyGroup,
|
||||||
getInstallationId,
|
getInstallationId,
|
||||||
|
getUniqueTenantId,
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,7 @@ async function createInstallDoc(platformDb: Database) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getInstallFromDB = async (): Promise<Installation> => {
|
export const getInstallFromDB = async (): Promise<Installation> => {
|
||||||
return doWithDB(
|
return doWithDB(
|
||||||
StaticDatabases.PLATFORM_INFO.name,
|
StaticDatabases.PLATFORM_INFO.name,
|
||||||
async (platformDb: any) => {
|
async (platformDb: any) => {
|
||||||
|
|
|
@ -44,7 +44,7 @@ async function checkApiKey(apiKey: string, populateUser?: Function) {
|
||||||
// check both the primary and the fallback internal api keys
|
// check both the primary and the fallback internal api keys
|
||||||
// this allows for rotation
|
// this allows for rotation
|
||||||
if (isValidInternalAPIKey(apiKey)) {
|
if (isValidInternalAPIKey(apiKey)) {
|
||||||
return { valid: true }
|
return { valid: true, user: undefined }
|
||||||
}
|
}
|
||||||
const decrypted = decrypt(apiKey)
|
const decrypted = decrypt(apiKey)
|
||||||
const tenantId = decrypted.split(SEPARATOR)[0]
|
const tenantId = decrypted.split(SEPARATOR)[0]
|
||||||
|
|
|
@ -1,10 +1,16 @@
|
||||||
import Redlock, { Options } from "redlock"
|
import Redlock from "redlock"
|
||||||
import { getLockClient } from "./init"
|
import { getLockClient } from "./init"
|
||||||
import { LockOptions, LockType } from "@budibase/types"
|
import { LockOptions, LockType } from "@budibase/types"
|
||||||
import * as context from "../context"
|
import * as context from "../context"
|
||||||
import env from "../environment"
|
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) {
|
if (env.isTest() && type !== LockType.TRY_ONCE) {
|
||||||
return newRedlock(OPTIONS.TEST)
|
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 }
|
let options = { ...OPTIONS.DEFAULT, ...opts }
|
||||||
const redisWrapper = await getLockClient()
|
const redisWrapper = await getLockClient()
|
||||||
const client = redisWrapper.getClient()
|
const client = redisWrapper.getClient()
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
import {
|
import {
|
||||||
ViewName,
|
|
||||||
getUsersByAppParams,
|
|
||||||
getProdAppID,
|
|
||||||
generateAppUserID,
|
|
||||||
queryGlobalView,
|
|
||||||
UNICODE_MAX,
|
|
||||||
DocumentType,
|
|
||||||
SEPARATOR,
|
|
||||||
directCouchFind,
|
directCouchFind,
|
||||||
|
DocumentType,
|
||||||
|
generateAppUserID,
|
||||||
getGlobalUserParams,
|
getGlobalUserParams,
|
||||||
|
getProdAppID,
|
||||||
|
getUsersByAppParams,
|
||||||
pagination,
|
pagination,
|
||||||
|
queryGlobalView,
|
||||||
|
queryGlobalViewRaw,
|
||||||
|
SEPARATOR,
|
||||||
|
UNICODE_MAX,
|
||||||
|
ViewName,
|
||||||
} from "./db"
|
} from "./db"
|
||||||
import { BulkDocsResponse, SearchUsersRequest, User } from "@budibase/types"
|
import { BulkDocsResponse, SearchUsersRequest, User } from "@budibase/types"
|
||||||
import { getGlobalDB } from "./context"
|
import { getGlobalDB } from "./context"
|
||||||
|
@ -239,3 +240,11 @@ export const paginatedUsers = async ({
|
||||||
getKey,
|
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
|
// search prod apps for a url that matches
|
||||||
const apps: App[] = await context.doInTenant(tenantId, () =>
|
const apps: App[] = await context.doInTenant(
|
||||||
getAllApps({ dev: false })
|
tenantId,
|
||||||
|
() => getAllApps({ dev: false }) as Promise<App[]>
|
||||||
)
|
)
|
||||||
const app = apps.filter(
|
const app = apps.filter(
|
||||||
a => a.url && a.url.toLowerCase() === possibleAppUrl
|
a => a.url && a.url.toLowerCase() === possibleAppUrl
|
||||||
|
@ -221,27 +222,6 @@ export function isClient(ctx: Ctx) {
|
||||||
return ctx.headers[Header.TYPE] === "client"
|
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) {
|
export function timeout(timeMs: number) {
|
||||||
return new Promise(resolve => setTimeout(resolve, timeMs))
|
return new Promise(resolve => setTimeout(resolve, timeMs))
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,5 +2,5 @@ export * as mocks from "./mocks"
|
||||||
export * as structures from "./structures"
|
export * as structures from "./structures"
|
||||||
export { generator } from "./structures"
|
export { generator } from "./structures"
|
||||||
export * as testContainerUtils from "./testContainerUtils"
|
export * as testContainerUtils from "./testContainerUtils"
|
||||||
|
export * as utils from "./utils"
|
||||||
export * from "./jestUtils"
|
export * from "./jestUtils"
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import * as events from "../../../../src/events"
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const processors = await import("../../../../src/events/processors")
|
const processors = await import("../../../../src/events/processors")
|
||||||
const events = await import("../../../../src/events")
|
const events = await import("../../../../src/events")
|
||||||
|
@ -120,4 +122,13 @@ beforeAll(async () => {
|
||||||
jest.spyOn(events.plugin, "init")
|
jest.spyOn(events.plugin, "init")
|
||||||
jest.spyOn(events.plugin, "imported")
|
jest.spyOn(events.plugin, "imported")
|
||||||
jest.spyOn(events.plugin, "deleted")
|
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 { generateGlobalUserID } from "../../../../src/docIds"
|
||||||
import {
|
import {
|
||||||
Account,
|
Account,
|
||||||
|
@ -28,6 +28,7 @@ export const account = (): Account => {
|
||||||
name: generator.name(),
|
name: generator.name(),
|
||||||
size: "10+",
|
size: "10+",
|
||||||
profession: "Software Engineer",
|
profession: "Software Engineer",
|
||||||
|
quotaUsage: quotas.usage(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
import Chance from "chance"
|
import Chance from "./Chance"
|
||||||
export const generator = new Chance()
|
export const generator = new Chance()
|
||||||
|
|
|
@ -11,3 +11,4 @@ export * as users from "./users"
|
||||||
export * as userGroups from "./userGroups"
|
export * as userGroups from "./userGroups"
|
||||||
export { generator } from "./generator"
|
export { generator } from "./generator"
|
||||||
export * as scim from "./scim"
|
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 {
|
return {
|
||||||
type,
|
type,
|
||||||
|
usesInvoicing: false,
|
||||||
|
minUsers: 1,
|
||||||
|
model: PlanModel.PER_USER,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const newLicense = (opts: {
|
export function quotas(): Quotas {
|
||||||
quotas: Quotas
|
|
||||||
planType?: PlanType
|
|
||||||
}): License => {
|
|
||||||
return {
|
return {
|
||||||
features: [],
|
usage: {
|
||||||
quotas: opts.quotas,
|
monthly: {
|
||||||
plan: newPlan(opts.planType),
|
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 message = ""
|
||||||
export let onConfirm = undefined
|
export let onConfirm = undefined
|
||||||
export let buttonText = ""
|
export let buttonText = ""
|
||||||
|
export let cta = false
|
||||||
$: icon = selectIcon(type)
|
$: icon = selectIcon(type)
|
||||||
// if newlines used, convert them to different elements
|
// if newlines used, convert them to different elements
|
||||||
$: split = message.split("\n")
|
$: split = message.split("\n")
|
||||||
|
@ -41,7 +41,9 @@
|
||||||
{/each}
|
{/each}
|
||||||
{#if onConfirm}
|
{#if onConfirm}
|
||||||
<div class="spectrum-InLineAlert-footer button">
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
@ -57,7 +59,6 @@
|
||||||
--spectrum-semantic-negative-icon-color: #e34850;
|
--spectrum-semantic-negative-icon-color: #e34850;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
border-color: var(--spectrum-global-color-gray-400);
|
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
export let wide = false
|
export let wide = false
|
||||||
export let narrow = false
|
export let narrow = false
|
||||||
|
export let narrower = false
|
||||||
export let noPadding = false
|
export let noPadding = false
|
||||||
|
|
||||||
let sidePanelVisble = false
|
let sidePanelVisble = false
|
||||||
|
@ -16,7 +17,7 @@
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="main">
|
<div class="main">
|
||||||
<div class="content" class:wide class:noPadding class:narrow>
|
<div class="content" class:wide class:noPadding class:narrow class:narrower>
|
||||||
<slot />
|
<slot />
|
||||||
<div class="fix-scroll-padding" />
|
<div class="fix-scroll-padding" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -70,6 +71,9 @@
|
||||||
.content.narrow {
|
.content.narrow {
|
||||||
max-width: 840px;
|
max-width: 840px;
|
||||||
}
|
}
|
||||||
|
.content.narrower {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
#side-panel {
|
#side-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { writable } from "svelte/store"
|
||||||
export const BANNER_TYPES = {
|
export const BANNER_TYPES = {
|
||||||
INFO: "info",
|
INFO: "info",
|
||||||
NEGATIVE: "negative",
|
NEGATIVE: "negative",
|
||||||
|
WARNING: "warning",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBannerStore() {
|
export function createBannerStore() {
|
||||||
|
@ -38,7 +39,8 @@ export function createBannerStore() {
|
||||||
const queue = async entries => {
|
const queue = async entries => {
|
||||||
const priority = {
|
const priority = {
|
||||||
[BANNER_TYPES.NEGATIVE]: 0,
|
[BANNER_TYPES.NEGATIVE]: 0,
|
||||||
[BANNER_TYPES.INFO]: 1,
|
[BANNER_TYPES.WARNING]: 1,
|
||||||
|
[BANNER_TYPES.INFO]: 2,
|
||||||
}
|
}
|
||||||
banner.update(store => {
|
banner.update(store => {
|
||||||
const sorted = [...store.messages, ...entries].sort((a, b) => {
|
const sorted = [...store.messages, ...entries].sort((a, b) => {
|
||||||
|
|
|
@ -3,9 +3,13 @@
|
||||||
|
|
||||||
export let size = "M"
|
export let size = "M"
|
||||||
export let serif = false
|
export let serif = false
|
||||||
|
export let weight = 600
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
|
style={`
|
||||||
|
${weight ? `font-weight:${weight};` : ""}
|
||||||
|
`}
|
||||||
class="spectrum-Detail spectrum-Detail--size{size}"
|
class="spectrum-Detail spectrum-Detail--size{size}"
|
||||||
class:spectrum-Detail--serif={serif}
|
class:spectrum-Detail--serif={serif}
|
||||||
>
|
>
|
||||||
|
@ -13,7 +17,4 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
p {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
</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_ROWS_WARNING_BANNER: "licensing_rows_warning_banner",
|
||||||
LICENSING_AUTOMATIONS_WARNING_BANNER: "licensing_automations_warning_banner",
|
LICENSING_AUTOMATIONS_WARNING_BANNER: "licensing_automations_warning_banner",
|
||||||
LICENSING_QUERIES_WARNING_BANNER: "licensing_queries_warning_banner",
|
LICENSING_QUERIES_WARNING_BANNER: "licensing_queries_warning_banner",
|
||||||
|
LICENSING_USERS_ABOVE_LIMIT_BANNER: "licensing_users_above_limit_banner",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StripeStatus = {
|
export const StripeStatus = {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { temporalStore } from "builderStore"
|
||||||
import { admin, auth, licensing } from "stores/portal"
|
import { admin, auth, licensing } from "stores/portal"
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
import { BANNER_TYPES } from "@budibase/bbui"
|
import { BANNER_TYPES } from "@budibase/bbui"
|
||||||
|
import { capitalise } from "helpers"
|
||||||
|
|
||||||
const oneDayInSeconds = 86400
|
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 = () => {
|
export const getBanners = () => {
|
||||||
return [
|
return [
|
||||||
buildPaymentFailedBanner(),
|
buildPaymentFailedBanner(),
|
||||||
|
@ -163,6 +188,7 @@ export const getBanners = () => {
|
||||||
ExpiringKeys.LICENSING_QUERIES_WARNING_BANNER,
|
ExpiringKeys.LICENSING_QUERIES_WARNING_BANNER,
|
||||||
90
|
90
|
||||||
),
|
),
|
||||||
|
buildUsersAboveLimitBanner(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER),
|
||||||
].filter(licensingBanner => {
|
].filter(licensingBanner => {
|
||||||
return (
|
return (
|
||||||
!temporalStore.actions.getExpiring(licensingBanner.key) &&
|
!temporalStore.actions.getExpiring(licensingBanner.key) &&
|
||||||
|
|
|
@ -67,3 +67,8 @@ export const OnboardingType = {
|
||||||
EMAIL: "email",
|
EMAIL: "email",
|
||||||
PASSWORD: "password",
|
PASSWORD: "password",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PlanModel = {
|
||||||
|
PER_USER: "perUser",
|
||||||
|
DAY_PASS: "dayPass",
|
||||||
|
}
|
||||||
|
|
|
@ -8,14 +8,16 @@
|
||||||
notifications,
|
notifications,
|
||||||
ActionButton,
|
ActionButton,
|
||||||
CopyInput,
|
CopyInput,
|
||||||
|
Modal,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { store } from "builderStore"
|
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 { fetchData } from "@budibase/frontend-core"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte"
|
import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte"
|
||||||
import RoleSelect from "components/common/RoleSelect.svelte"
|
import RoleSelect from "components/common/RoleSelect.svelte"
|
||||||
|
import UpgradeModal from "components/common/users/UpgradeModal.svelte"
|
||||||
import { Constants, Utils } from "@budibase/frontend-core"
|
import { Constants, Utils } from "@budibase/frontend-core"
|
||||||
import { emailValidator } from "helpers/validation"
|
import { emailValidator } from "helpers/validation"
|
||||||
import { roles } from "stores/backend"
|
import { roles } from "stores/backend"
|
||||||
|
@ -33,6 +35,8 @@
|
||||||
let selectedGroup
|
let selectedGroup
|
||||||
let userOnboardResponse = null
|
let userOnboardResponse = null
|
||||||
|
|
||||||
|
let userLimitReachedModal
|
||||||
|
|
||||||
$: queryIsEmail = emailValidator(query) === true
|
$: queryIsEmail = emailValidator(query) === true
|
||||||
$: prodAppId = apps.getProdAppID($store.appId)
|
$: prodAppId = apps.getProdAppID($store.appId)
|
||||||
$: promptInvite = showInvite(
|
$: promptInvite = showInvite(
|
||||||
|
@ -41,6 +45,7 @@
|
||||||
filteredGroups,
|
filteredGroups,
|
||||||
query
|
query
|
||||||
)
|
)
|
||||||
|
$: isOwner = $auth.accountPortalAccess && $admin.cloud
|
||||||
|
|
||||||
const showInvite = (invites, users, groups, query) => {
|
const showInvite = (invites, users, groups, query) => {
|
||||||
return !invites?.length && !users?.length && !groups?.length && query
|
return !invites?.length && !users?.length && !groups?.length && query
|
||||||
|
@ -450,7 +455,9 @@
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon="UserAdd"
|
icon="UserAdd"
|
||||||
disabled={!queryIsEmail || inviting}
|
disabled={!queryIsEmail || inviting}
|
||||||
on:click={onInviteUser}
|
on:click={$licensing.userLimitReached
|
||||||
|
? userLimitReachedModal.show
|
||||||
|
: onInviteUser}
|
||||||
>
|
>
|
||||||
Add user
|
Add user
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
@ -608,6 +615,9 @@
|
||||||
</Layout>
|
</Layout>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<Modal bind:this={userLimitReachedModal}>
|
||||||
|
<UpgradeModal {isOwner} />
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
@ -128,7 +128,7 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ButtonGroup>
|
<ButtonGroup gap="M">
|
||||||
<Button cta on:click={activate} disabled={activateDisabled}>
|
<Button cta on:click={activate} disabled={activateDisabled}>
|
||||||
Activate
|
Activate
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
import { admin, auth, licensing } from "stores/portal"
|
import { admin, auth, licensing } from "stores/portal"
|
||||||
import { Constants } from "@budibase/frontend-core"
|
import { Constants } from "@budibase/frontend-core"
|
||||||
import { DashCard, Usage } from "components/usage"
|
import { DashCard, Usage } from "components/usage"
|
||||||
|
import { PlanModel } from "constants"
|
||||||
|
|
||||||
let staticUsage = []
|
let staticUsage = []
|
||||||
let monthlyUsage = []
|
let monthlyUsage = []
|
||||||
|
@ -25,8 +26,21 @@
|
||||||
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
|
const upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade`
|
||||||
const manageUrl = `${$admin.accountPortalUrl}/portal/billing`
|
const manageUrl = `${$admin.accountPortalUrl}/portal/billing`
|
||||||
|
|
||||||
const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes"]
|
const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes", "Users"]
|
||||||
const EXCLUDE_QUOTAS = ["Queries"]
|
|
||||||
|
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
|
$: quotaUsage = $licensing.quotaUsage
|
||||||
$: license = $auth.user?.license
|
$: license = $auth.user?.license
|
||||||
|
@ -39,7 +53,7 @@
|
||||||
monthlyUsage = []
|
monthlyUsage = []
|
||||||
if (quotaUsage.monthly) {
|
if (quotaUsage.monthly) {
|
||||||
for (let [key, value] of Object.entries(license.quotas.usage.monthly)) {
|
for (let [key, value] of Object.entries(license.quotas.usage.monthly)) {
|
||||||
if (EXCLUDE_QUOTAS.includes(value.name)) {
|
if (excludeQuota(value.name)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const used = quotaUsage.monthly.current[key]
|
const used = quotaUsage.monthly.current[key]
|
||||||
|
@ -58,7 +72,7 @@
|
||||||
const setStaticUsage = () => {
|
const setStaticUsage = () => {
|
||||||
staticUsage = []
|
staticUsage = []
|
||||||
for (let [key, value] of Object.entries(license.quotas.usage.static)) {
|
for (let [key, value] of Object.entries(license.quotas.usage.static)) {
|
||||||
if (EXCLUDE_QUOTAS.includes(value.name)) {
|
if (excludeQuota(value.name)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const used = quotaUsage.usageQuota[key]
|
const used = quotaUsage.usageQuota[key]
|
||||||
|
@ -84,7 +98,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const planTitle = () => {
|
const planTitle = () => {
|
||||||
return capitalise(license?.plan.type)
|
return `${capitalise(license?.plan.type)} Plan`
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDaysRemaining = timestamp => {
|
const getDaysRemaining = timestamp => {
|
||||||
|
@ -110,8 +124,8 @@
|
||||||
if (cancelAt) {
|
if (cancelAt) {
|
||||||
textRows.push({ message: "Subscription has been cancelled" })
|
textRows.push({ message: "Subscription has been cancelled" })
|
||||||
textRows.push({
|
textRows.push({
|
||||||
message: `${getDaysRemaining(cancelAt * 1000)} days remaining`,
|
message: `${getDaysRemaining(cancelAt)} days remaining`,
|
||||||
tooltip: new Date(cancelAt * 1000),
|
tooltip: new Date(cancelAt),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
import { groups, licensing } from "stores/portal"
|
import { groups, licensing } from "stores/portal"
|
||||||
import { Constants } from "@budibase/frontend-core"
|
import { Constants } from "@budibase/frontend-core"
|
||||||
import { emailValidator } from "helpers/validation"
|
import { emailValidator } from "helpers/validation"
|
||||||
|
import { capitalise } from "helpers"
|
||||||
|
|
||||||
export let showOnboardingTypeModal
|
export let showOnboardingTypeModal
|
||||||
|
|
||||||
|
@ -28,6 +29,10 @@
|
||||||
]
|
]
|
||||||
$: hasError = userData.find(x => x.error != null)
|
$: hasError = userData.find(x => x.error != null)
|
||||||
|
|
||||||
|
$: userCount = $licensing.userCount + userData.length
|
||||||
|
$: willReach = licensing.willReachUserLimit(userCount)
|
||||||
|
$: willExceed = licensing.willExceedUserLimit(userCount)
|
||||||
|
|
||||||
function removeInput(idx) {
|
function removeInput(idx) {
|
||||||
userData = userData.filter((e, i) => i !== idx)
|
userData = userData.filter((e, i) => i !== idx)
|
||||||
}
|
}
|
||||||
|
@ -82,7 +87,7 @@
|
||||||
confirmDisabled={disabled}
|
confirmDisabled={disabled}
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
showCloseIcon={false}
|
showCloseIcon={false}
|
||||||
disabled={hasError || !userData.length}
|
disabled={hasError || !userData.length || willExceed}
|
||||||
>
|
>
|
||||||
<Layout noPadding gap="XS">
|
<Layout noPadding gap="XS">
|
||||||
<Label>Email address</Label>
|
<Label>Email address</Label>
|
||||||
|
@ -112,9 +117,20 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<div>
|
|
||||||
<ActionButton on:click={addNewInput} icon="Add">Add email</ActionButton>
|
{#if willReach}
|
||||||
</div>
|
<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>
|
</Layout>
|
||||||
|
|
||||||
{#if $licensing.groupsEnabled}
|
{#if $licensing.groupsEnabled}
|
||||||
|
@ -130,6 +146,12 @@
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.user-notification {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
}
|
||||||
.icon {
|
.icon {
|
||||||
width: 10%;
|
width: 10%;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
|
|
|
@ -5,10 +5,12 @@
|
||||||
RadioGroup,
|
RadioGroup,
|
||||||
Multiselect,
|
Multiselect,
|
||||||
notifications,
|
notifications,
|
||||||
|
Icon,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { groups, licensing, admin } from "stores/portal"
|
import { groups, licensing, admin } from "stores/portal"
|
||||||
import { emailValidator } from "helpers/validation"
|
import { emailValidator } from "helpers/validation"
|
||||||
import { Constants } from "@budibase/frontend-core"
|
import { Constants } from "@budibase/frontend-core"
|
||||||
|
import { capitalise } from "helpers"
|
||||||
|
|
||||||
const BYTES_IN_MB = 1000000
|
const BYTES_IN_MB = 1000000
|
||||||
const FILE_SIZE_LIMIT = BYTES_IN_MB * 5
|
const FILE_SIZE_LIMIT = BYTES_IN_MB * 5
|
||||||
|
@ -20,9 +22,14 @@
|
||||||
let userEmails = []
|
let userEmails = []
|
||||||
let userGroups = []
|
let userGroups = []
|
||||||
let usersRole = null
|
let usersRole = null
|
||||||
|
|
||||||
$: invalidEmails = []
|
$: invalidEmails = []
|
||||||
|
|
||||||
|
$: userCount = $licensing.userCount + userEmails.length
|
||||||
|
$: willExceed = userCount > $licensing.userLimit
|
||||||
|
|
||||||
|
$: importDisabled =
|
||||||
|
!userEmails.length || !validEmails(userEmails) || !usersRole || willExceed
|
||||||
|
|
||||||
const validEmails = userEmails => {
|
const validEmails = userEmails => {
|
||||||
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) {
|
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) {
|
||||||
notifications.error(
|
notifications.error(
|
||||||
|
@ -75,7 +82,7 @@
|
||||||
cancelText="Cancel"
|
cancelText="Cancel"
|
||||||
showCloseIcon={false}
|
showCloseIcon={false}
|
||||||
onConfirm={() => createUsersFromCsv({ userEmails, usersRole, userGroups })}
|
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>
|
<Body size="S">Import your users email addresses from a CSV file</Body>
|
||||||
|
|
||||||
|
@ -86,6 +93,13 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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
|
<RadioGroup
|
||||||
bind:value={usersRole}
|
bind:value={usersRole}
|
||||||
options={Constants.BuilderRoleDescriptions}
|
options={Constants.BuilderRoleDescriptions}
|
||||||
|
@ -104,6 +118,13 @@
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.user-notification {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
}
|
||||||
|
|
||||||
.dropzone {
|
.dropzone {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
notifications,
|
notifications,
|
||||||
Pagination,
|
Pagination,
|
||||||
Divider,
|
Divider,
|
||||||
|
InlineAlert,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import AddUserModal from "./_components/AddUserModal.svelte"
|
import AddUserModal from "./_components/AddUserModal.svelte"
|
||||||
import {
|
import {
|
||||||
|
@ -20,9 +21,11 @@
|
||||||
licensing,
|
licensing,
|
||||||
organisation,
|
organisation,
|
||||||
features,
|
features,
|
||||||
|
admin,
|
||||||
} from "stores/portal"
|
} from "stores/portal"
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import DeleteRowsButton from "components/backend/DataTable/buttons/DeleteRowsButton.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 GroupsTableRenderer from "./_components/GroupsTableRenderer.svelte"
|
||||||
import AppsTableRenderer from "./_components/AppsTableRenderer.svelte"
|
import AppsTableRenderer from "./_components/AppsTableRenderer.svelte"
|
||||||
import RoleTableRenderer from "./_components/RoleTableRenderer.svelte"
|
import RoleTableRenderer from "./_components/RoleTableRenderer.svelte"
|
||||||
|
@ -50,7 +53,8 @@
|
||||||
inviteConfirmationModal,
|
inviteConfirmationModal,
|
||||||
onboardingTypeModal,
|
onboardingTypeModal,
|
||||||
passwordModal,
|
passwordModal,
|
||||||
importUsersModal
|
importUsersModal,
|
||||||
|
userLimitReachedModal
|
||||||
let searchEmail = undefined
|
let searchEmail = undefined
|
||||||
let selectedRows = []
|
let selectedRows = []
|
||||||
let bulkSaveResponse
|
let bulkSaveResponse
|
||||||
|
@ -61,7 +65,9 @@
|
||||||
]
|
]
|
||||||
let userData = []
|
let userData = []
|
||||||
|
|
||||||
|
$: isOwner = $auth.accountPortalAccess && $admin.cloud
|
||||||
$: readonly = !$auth.isAdmin || $features.isScimEnabled
|
$: readonly = !$auth.isAdmin || $features.isScimEnabled
|
||||||
|
|
||||||
$: debouncedUpdateFetch(searchEmail)
|
$: debouncedUpdateFetch(searchEmail)
|
||||||
$: schema = {
|
$: schema = {
|
||||||
email: {
|
email: {
|
||||||
|
@ -81,6 +87,7 @@
|
||||||
width: "1fr",
|
width: "1fr",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
$: userData = []
|
$: userData = []
|
||||||
$: inviteUsersResponse = { successful: [], unsuccessful: [] }
|
$: inviteUsersResponse = { successful: [], unsuccessful: [] }
|
||||||
$: {
|
$: {
|
||||||
|
@ -229,6 +236,8 @@
|
||||||
notifications.error("Error fetching user group data")
|
notifications.error("Error fetching user group data")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let staticUserLimit = $licensing.license.quotas.usage.static.users.value
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Layout noPadding gap="M">
|
<Layout noPadding gap="M">
|
||||||
|
@ -237,13 +246,46 @@
|
||||||
<Body>Add users and control who gets access to your published apps</Body>
|
<Body>Add users and control who gets access to your published apps</Body>
|
||||||
</Layout>
|
</Layout>
|
||||||
<Divider />
|
<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">
|
<div class="controls">
|
||||||
{#if !readonly}
|
{#if !readonly}
|
||||||
<ButtonGroup>
|
<ButtonGroup>
|
||||||
<Button disabled={readonly} on:click={createUserModal.show} cta>
|
<Button
|
||||||
|
disabled={readonly}
|
||||||
|
on:click={$licensing.userLimitReached
|
||||||
|
? userLimitReachedModal.show
|
||||||
|
: createUserModal.show}
|
||||||
|
cta
|
||||||
|
>
|
||||||
Add users
|
Add users
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={readonly} on:click={importUsersModal.show} secondary>
|
<Button
|
||||||
|
disabled={readonly}
|
||||||
|
on:click={$licensing.userLimitReached
|
||||||
|
? userLimitReachedModal.show
|
||||||
|
: importUsersModal.show}
|
||||||
|
secondary
|
||||||
|
>
|
||||||
Import
|
Import
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
@ -307,6 +349,10 @@
|
||||||
<ImportUsersModal {createUsersFromCsv} />
|
<ImportUsersModal {createUsersFromCsv} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal bind:this={userLimitReachedModal}>
|
||||||
|
<UpgradeModal {isOwner} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -319,7 +365,6 @@
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--spacing-xl);
|
gap: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,9 @@ import { auth, admin } from "stores/portal"
|
||||||
import { Constants } from "@budibase/frontend-core"
|
import { Constants } from "@budibase/frontend-core"
|
||||||
import { StripeStatus } from "components/portal/licensing/constants"
|
import { StripeStatus } from "components/portal/licensing/constants"
|
||||||
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
|
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
|
||||||
|
const UNLIMITED = -1
|
||||||
|
|
||||||
export const createLicensingStore = () => {
|
export const createLicensingStore = () => {
|
||||||
const DEFAULT = {
|
const DEFAULT = {
|
||||||
|
@ -31,17 +34,37 @@ export const createLicensingStore = () => {
|
||||||
pastDueEndDate: undefined,
|
pastDueEndDate: undefined,
|
||||||
pastDueDaysRemaining: undefined,
|
pastDueDaysRemaining: undefined,
|
||||||
accountDowngraded: undefined,
|
accountDowngraded: undefined,
|
||||||
|
// user limits
|
||||||
|
userCount: undefined,
|
||||||
|
userLimit: undefined,
|
||||||
|
userLimitDays: undefined,
|
||||||
|
userLimitReached: false,
|
||||||
|
warnUserLimit: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const oneDayInMilliseconds = 86400000
|
const oneDayInMilliseconds = 86400000
|
||||||
|
|
||||||
const store = writable(DEFAULT)
|
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 = {
|
const actions = {
|
||||||
init: async () => {
|
init: async () => {
|
||||||
actions.setNavigation()
|
actions.setNavigation()
|
||||||
actions.setLicense()
|
actions.setLicense()
|
||||||
await actions.setQuotaUsage()
|
await actions.setQuotaUsage()
|
||||||
actions.setUsageMetrics()
|
|
||||||
},
|
},
|
||||||
setNavigation: () => {
|
setNavigation: () => {
|
||||||
const upgradeUrl = `${get(admin).accountPortalUrl}/portal/upgrade`
|
const upgradeUrl = `${get(admin).accountPortalUrl}/portal/upgrade`
|
||||||
|
@ -105,10 +128,17 @@ export const createLicensingStore = () => {
|
||||||
quotaUsage,
|
quotaUsage,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
actions.setUsageMetrics()
|
||||||
|
},
|
||||||
|
willReachUserLimit: userCount => {
|
||||||
|
return willReachUserLimit(userCount, get(store).userLimit)
|
||||||
|
},
|
||||||
|
willExceedUserLimit(userCount) {
|
||||||
|
return willExceedUserLimit(userCount, get(store).userLimit)
|
||||||
},
|
},
|
||||||
setUsageMetrics: () => {
|
setUsageMetrics: () => {
|
||||||
if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) {
|
if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) {
|
||||||
const quota = get(store).quotaUsage
|
const usage = get(store).quotaUsage
|
||||||
const license = get(auth).user.license
|
const license = get(auth).user.license
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
|
@ -126,12 +156,12 @@ export const createLicensingStore = () => {
|
||||||
const monthlyMetrics = getMetrics(
|
const monthlyMetrics = getMetrics(
|
||||||
["dayPasses", "queries", "automations"],
|
["dayPasses", "queries", "automations"],
|
||||||
license.quotas.usage.monthly,
|
license.quotas.usage.monthly,
|
||||||
quota.monthly.current
|
usage.monthly.current
|
||||||
)
|
)
|
||||||
const staticMetrics = getMetrics(
|
const staticMetrics = getMetrics(
|
||||||
["apps", "rows"],
|
["apps", "rows"],
|
||||||
license.quotas.usage.static,
|
license.quotas.usage.static,
|
||||||
quota.usageQuota
|
usage.usageQuota
|
||||||
)
|
)
|
||||||
|
|
||||||
const getDaysBetween = (dateStart, dateEnd) => {
|
const getDaysBetween = (dateStart, dateEnd) => {
|
||||||
|
@ -142,7 +172,7 @@ export const createLicensingStore = () => {
|
||||||
: 0
|
: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotaResetDate = new Date(quota.quotaReset)
|
const quotaResetDate = new Date(usage.quotaReset)
|
||||||
const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate)
|
const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate)
|
||||||
|
|
||||||
const accountDowngraded =
|
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 => {
|
store.update(state => {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
@ -175,6 +214,12 @@ export const createLicensingStore = () => {
|
||||||
accountPastDue: pastDueAtMilliseconds != null,
|
accountPastDue: pastDueAtMilliseconds != null,
|
||||||
pastDueEndDate,
|
pastDueEndDate,
|
||||||
pastDueDaysRemaining,
|
pastDueDaysRemaining,
|
||||||
|
// user limits
|
||||||
|
userCount,
|
||||||
|
userLimit,
|
||||||
|
userLimitDays,
|
||||||
|
userLimitReached,
|
||||||
|
warnUserLimit,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { writable } from "svelte/store"
|
import { writable } from "svelte/store"
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
import { update } from "lodash"
|
import { update } from "lodash"
|
||||||
|
import { licensing } from "."
|
||||||
|
|
||||||
export function createUsersStore() {
|
export function createUsersStore() {
|
||||||
const { subscribe, set } = writable({})
|
const { subscribe, set } = writable({})
|
||||||
|
@ -113,6 +114,12 @@ export function createUsersStore() {
|
||||||
const getUserRole = ({ admin, builder }) =>
|
const getUserRole = ({ admin, builder }) =>
|
||||||
admin?.global ? "admin" : builder?.global ? "developer" : "appUser"
|
admin?.global ? "admin" : builder?.global ? "developer" : "appUser"
|
||||||
|
|
||||||
|
const refreshUsage = fn => async args => {
|
||||||
|
const response = await fn(args)
|
||||||
|
await licensing.setQuotaUsage()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
search,
|
search,
|
||||||
|
@ -121,15 +128,16 @@ export function createUsersStore() {
|
||||||
fetch,
|
fetch,
|
||||||
invite,
|
invite,
|
||||||
onboard,
|
onboard,
|
||||||
acceptInvite,
|
|
||||||
fetchInvite,
|
fetchInvite,
|
||||||
getInvites,
|
getInvites,
|
||||||
updateInvite,
|
updateInvite,
|
||||||
create,
|
|
||||||
save,
|
|
||||||
bulkDelete,
|
|
||||||
getUserCountByApp,
|
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 syncApps from "./usageQuotas/syncApps"
|
||||||
import * as syncRows from "./usageQuotas/syncRows"
|
import * as syncRows from "./usageQuotas/syncRows"
|
||||||
import * as syncPlugins from "./usageQuotas/syncPlugins"
|
import * as syncPlugins from "./usageQuotas/syncPlugins"
|
||||||
|
import * as syncUsers from "./usageQuotas/syncUsers"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronise quotas to the state of the db.
|
* Synchronise quotas to the state of the db.
|
||||||
|
@ -11,5 +12,6 @@ export const run = async () => {
|
||||||
await syncApps.run()
|
await syncApps.run()
|
||||||
await syncRows.run()
|
await syncRows.run()
|
||||||
await syncPlugins.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 { quotas } from "@budibase/pro"
|
||||||
import { QuotaUsageType, StaticQuotaName } from "@budibase/types"
|
import { QuotaUsageType, StaticQuotaName } from "@budibase/types"
|
||||||
|
|
||||||
|
@ -8,7 +8,6 @@ export const run = async () => {
|
||||||
const appCount = devApps ? devApps.length : 0
|
const appCount = devApps ? devApps.length : 0
|
||||||
|
|
||||||
// sync app count
|
// sync app count
|
||||||
const tenantId = tenancy.getTenantId()
|
|
||||||
console.log(`Syncing app count: ${appCount}`)
|
console.log(`Syncing app count: ${appCount}`)
|
||||||
await quotas.setUsage(appCount, StaticQuotaName.APPS, QuotaUsageType.STATIC)
|
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
|
// migration functions
|
||||||
import * as userEmailViewCasing from "./functions/userEmailViewCasing"
|
import * as userEmailViewCasing from "./functions/userEmailViewCasing"
|
||||||
import * as syncQuotas from "./functions/syncQuotas"
|
import * as syncQuotas from "./functions/syncQuotas"
|
||||||
|
import * as syncUsers from "./functions/usageQuotas/syncUsers"
|
||||||
import * as appUrls from "./functions/appUrls"
|
import * as appUrls from "./functions/appUrls"
|
||||||
import * as tableSettings from "./functions/tableSettings"
|
import * as tableSettings from "./functions/tableSettings"
|
||||||
import * as backfill from "./functions/backfill"
|
import * as backfill from "./functions/backfill"
|
||||||
|
|
|
@ -25,6 +25,7 @@
|
||||||
"@types/koa": "2.13.4",
|
"@types/koa": "2.13.4",
|
||||||
"@types/node": "14.18.20",
|
"@types/node": "14.18.20",
|
||||||
"@types/pouchdb": "6.4.0",
|
"@types/pouchdb": "6.4.0",
|
||||||
|
"@types/redlock": "4.0.3",
|
||||||
"concurrently": "^7.6.0",
|
"concurrently": "^7.6.0",
|
||||||
"koa-body": "4.2.0",
|
"koa-body": "4.2.0",
|
||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
|
|
|
@ -1,7 +1,14 @@
|
||||||
import { QuotaUsage } from "../../documents"
|
import { QuotaUsage } from "../../documents"
|
||||||
|
|
||||||
export interface GetLicenseRequest {
|
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 {
|
export interface QuotaTriggeredRequest {
|
||||||
|
@ -9,3 +16,7 @@ export interface QuotaTriggeredRequest {
|
||||||
name: string
|
name: string
|
||||||
resetDate?: 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 {
|
export interface BulkUserResponse {
|
||||||
created?: {
|
created?: BulkUserCreated
|
||||||
successful: UserDetails[]
|
deleted?: BulkUserDeleted
|
||||||
unsuccessful: { email: string; reason: string }[]
|
|
||||||
}
|
|
||||||
deleted?: {
|
|
||||||
successful: UserDetails[]
|
|
||||||
unsuccessful: { _id: string; email: string; reason: string }[]
|
|
||||||
}
|
|
||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,5 @@
|
||||||
import {
|
import { Feature, Hosting, License, PlanType, Quotas } from "../../sdk"
|
||||||
Feature,
|
import { QuotaUsage } from "../global"
|
||||||
Hosting,
|
|
||||||
License,
|
|
||||||
MonthlyQuotaName,
|
|
||||||
PlanType,
|
|
||||||
PriceDuration,
|
|
||||||
Quotas,
|
|
||||||
StaticQuotaName,
|
|
||||||
} from "../../sdk"
|
|
||||||
import { MonthlyUsage, QuotaUsage, StaticUsage } from "../global"
|
|
||||||
|
|
||||||
export interface CreateAccount {
|
export interface CreateAccount {
|
||||||
email: string
|
email: string
|
||||||
|
@ -49,6 +40,9 @@ export interface Account extends CreateAccount {
|
||||||
planType?: PlanType
|
planType?: PlanType
|
||||||
planTier?: number
|
planTier?: number
|
||||||
license?: License
|
license?: License
|
||||||
|
installId?: string
|
||||||
|
installTenantId?: string
|
||||||
|
installVersion?: string
|
||||||
stripeCustomerId?: string
|
stripeCustomerId?: string
|
||||||
licenseKey?: string
|
licenseKey?: string
|
||||||
licenseKeyActivatedAt?: number
|
licenseKeyActivatedAt?: number
|
||||||
|
|
|
@ -31,6 +31,7 @@ export type QuotaTriggers = {
|
||||||
export interface StaticUsage {
|
export interface StaticUsage {
|
||||||
[StaticQuotaName.APPS]: number
|
[StaticQuotaName.APPS]: number
|
||||||
[StaticQuotaName.PLUGINS]: number
|
[StaticQuotaName.PLUGINS]: number
|
||||||
|
[StaticQuotaName.USERS]: number
|
||||||
[StaticQuotaName.USER_GROUPS]: number
|
[StaticQuotaName.USER_GROUPS]: number
|
||||||
[StaticQuotaName.ROWS]: number
|
[StaticQuotaName.ROWS]: number
|
||||||
triggers: {
|
triggers: {
|
||||||
|
|
|
@ -7,6 +7,7 @@ export interface Customer {
|
||||||
|
|
||||||
export interface Subscription {
|
export interface Subscription {
|
||||||
amount: number
|
amount: number
|
||||||
|
currency: string
|
||||||
quantity: number
|
quantity: number
|
||||||
duration: PriceDuration
|
duration: PriceDuration
|
||||||
cancelAt: number | null | undefined
|
cancelAt: number | null | undefined
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { PlanType } from "./plan"
|
||||||
|
|
||||||
export enum Feature {
|
export enum Feature {
|
||||||
USER_GROUPS = "userGroups",
|
USER_GROUPS = "userGroups",
|
||||||
APP_BACKUPS = "appBackups",
|
APP_BACKUPS = "appBackups",
|
||||||
|
@ -7,3 +9,5 @@ export enum Feature {
|
||||||
BRANDING = "branding",
|
BRANDING = "branding",
|
||||||
SCIM = "scim",
|
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 {
|
export interface License {
|
||||||
features: Feature[]
|
features: Feature[]
|
||||||
quotas: Quotas
|
quotas: Quotas
|
||||||
plan: AccountPlan
|
plan: PurchasedPlan
|
||||||
billing?: Billing
|
billing?: Billing
|
||||||
|
testClockId?: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
export interface AccountPlan {
|
|
||||||
type: PlanType
|
|
||||||
price?: Price
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum PlanType {
|
export enum PlanType {
|
||||||
FREE = "free",
|
FREE = "free",
|
||||||
|
/** @deprecated */
|
||||||
PRO = "pro",
|
PRO = "pro",
|
||||||
|
/** @deprecated */
|
||||||
TEAM = "team",
|
TEAM = "team",
|
||||||
|
PREMIUM = "premium",
|
||||||
BUSINESS = "business",
|
BUSINESS = "business",
|
||||||
ENTERPRISE = "enterprise",
|
ENTERPRISE = "enterprise",
|
||||||
}
|
}
|
||||||
|
@ -16,12 +14,36 @@ export enum PriceDuration {
|
||||||
YEARLY = "yearly",
|
YEARLY = "yearly",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Price {
|
export interface AvailablePlan {
|
||||||
|
type: PlanType
|
||||||
|
maxUsers: number
|
||||||
|
minUsers: number
|
||||||
|
prices: AvailablePrice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailablePrice {
|
||||||
amount: number
|
amount: number
|
||||||
amountMonthly: number
|
amountMonthly: number
|
||||||
currency: string
|
currency: string
|
||||||
duration: PriceDuration
|
duration: PriceDuration
|
||||||
priceId: string
|
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
|
isPerUser: boolean
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ export enum QuotaType {
|
||||||
export enum StaticQuotaName {
|
export enum StaticQuotaName {
|
||||||
ROWS = "rows",
|
ROWS = "rows",
|
||||||
APPS = "apps",
|
APPS = "apps",
|
||||||
|
USERS = "users",
|
||||||
USER_GROUPS = "userGroups",
|
USER_GROUPS = "userGroups",
|
||||||
PLUGINS = "plugins",
|
PLUGINS = "plugins",
|
||||||
}
|
}
|
||||||
|
@ -54,14 +55,14 @@ export const isConstantQuota = (
|
||||||
return quotaType === QuotaType.CONSTANT
|
return quotaType === QuotaType.CONSTANT
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PlanQuotas = {
|
export interface Minimums {
|
||||||
[PlanType.FREE]: Quotas
|
users: number
|
||||||
[PlanType.PRO]: Quotas
|
|
||||||
[PlanType.TEAM]: Quotas
|
|
||||||
[PlanType.BUSINESS]: Quotas
|
|
||||||
[PlanType.ENTERPRISE]: Quotas
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PlanMinimums = { [key in PlanType]: Minimums }
|
||||||
|
|
||||||
|
export type PlanQuotas = { [key in PlanType]: Quotas | undefined }
|
||||||
|
|
||||||
export type MonthlyQuotas = {
|
export type MonthlyQuotas = {
|
||||||
[MonthlyQuotaName.QUERIES]: Quota
|
[MonthlyQuotaName.QUERIES]: Quota
|
||||||
[MonthlyQuotaName.AUTOMATIONS]: Quota
|
[MonthlyQuotaName.AUTOMATIONS]: Quota
|
||||||
|
@ -71,6 +72,7 @@ export type MonthlyQuotas = {
|
||||||
export type StaticQuotas = {
|
export type StaticQuotas = {
|
||||||
[StaticQuotaName.ROWS]: Quota
|
[StaticQuotaName.ROWS]: Quota
|
||||||
[StaticQuotaName.APPS]: Quota
|
[StaticQuotaName.APPS]: Quota
|
||||||
|
[StaticQuotaName.USERS]: Quota
|
||||||
[StaticQuotaName.USER_GROUPS]: Quota
|
[StaticQuotaName.USER_GROUPS]: Quota
|
||||||
[StaticQuotaName.PLUGINS]: Quota
|
[StaticQuotaName.PLUGINS]: Quota
|
||||||
}
|
}
|
||||||
|
@ -99,4 +101,5 @@ export interface Quota {
|
||||||
* which can have subsequent effects such as sending emails to users.
|
* which can have subsequent effects such as sending emails to users.
|
||||||
*/
|
*/
|
||||||
triggers: number[]
|
triggers: number[]
|
||||||
|
startDate?: number
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import Redlock from "redlock"
|
||||||
|
|
||||||
export enum LockType {
|
export enum LockType {
|
||||||
/**
|
/**
|
||||||
* If this lock is already held the attempted operation will not be performed.
|
* If this lock is already held the attempted operation will not be performed.
|
||||||
|
@ -6,6 +8,7 @@ export enum LockType {
|
||||||
TRY_ONCE = "try_once",
|
TRY_ONCE = "try_once",
|
||||||
DEFAULT = "default",
|
DEFAULT = "default",
|
||||||
DELAY_500 = "delay_500",
|
DELAY_500 = "delay_500",
|
||||||
|
CUSTOM = "custom",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum LockName {
|
export enum LockName {
|
||||||
|
@ -14,6 +17,7 @@ export enum LockName {
|
||||||
SYNC_ACCOUNT_LICENSE = "sync_account_license",
|
SYNC_ACCOUNT_LICENSE = "sync_account_license",
|
||||||
UPDATE_TENANTS_DOC = "update_tenants_doc",
|
UPDATE_TENANTS_DOC = "update_tenants_doc",
|
||||||
PERSIST_WRITETHROUGH = "persist_writethrough",
|
PERSIST_WRITETHROUGH = "persist_writethrough",
|
||||||
|
QUOTA_USAGE_EVENT = "quota_usage_event",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LockOptions {
|
export interface LockOptions {
|
||||||
|
@ -21,6 +25,11 @@ export interface LockOptions {
|
||||||
* The lock type determines which client to use
|
* The lock type determines which client to use
|
||||||
*/
|
*/
|
||||||
type: LockType
|
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
|
* The name for the lock
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -329,6 +329,7 @@ export const checkInvite = async (ctx: any) => {
|
||||||
try {
|
try {
|
||||||
invite = await checkInviteCode(code, false)
|
invite = await checkInviteCode(code, false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.warn("Error getting invite from code", e)
|
||||||
ctx.throw(400, "There was a problem with the invite")
|
ctx.throw(400, "There was a problem with the invite")
|
||||||
}
|
}
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
@ -415,8 +416,8 @@ export const inviteAccept = async (
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
_id: user._id,
|
_id: user._id!,
|
||||||
_rev: user._rev,
|
_rev: user._rev!,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
|
@ -20,7 +20,6 @@ import {
|
||||||
import {
|
import {
|
||||||
AccountMetadata,
|
AccountMetadata,
|
||||||
AllDocsResponse,
|
AllDocsResponse,
|
||||||
BulkUserResponse,
|
|
||||||
CloudAccount,
|
CloudAccount,
|
||||||
InviteUsersRequest,
|
InviteUsersRequest,
|
||||||
InviteUsersResponse,
|
InviteUsersResponse,
|
||||||
|
@ -31,6 +30,8 @@ import {
|
||||||
RowResponse,
|
RowResponse,
|
||||||
User,
|
User,
|
||||||
SaveUserOpts,
|
SaveUserOpts,
|
||||||
|
BulkUserCreated,
|
||||||
|
BulkUserDeleted,
|
||||||
Account,
|
Account,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { sendEmail } from "../../utilities/email"
|
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))
|
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 (
|
export const save = async (
|
||||||
user: User,
|
user: User,
|
||||||
opts: SaveUserOpts = {}
|
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)
|
let builtUser = await buildUser(user, opts, tenantId, dbUser)
|
||||||
// don't allow a user to update its own roles/perms
|
// don't allow a user to update its own roles/perms
|
||||||
if (opts.currentUserId && opts.currentUserId === dbUser?._id) {
|
if (opts.currentUserId && opts.currentUserId === dbUser?._id) {
|
||||||
builtUser.builder = dbUser.builder
|
builtUser.builder = dbUser.builder
|
||||||
builtUser.admin = dbUser.admin
|
builtUser.admin = dbUser.admin
|
||||||
builtUser.roles = dbUser.roles
|
builtUser.roles = dbUser.roles
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dbUser && roles?.length) {
|
if (!dbUser && roles?.length) {
|
||||||
builtUser.roles = { ...roles }
|
builtUser.roles = { ...roles }
|
||||||
}
|
}
|
||||||
|
|
||||||
// make sure we set the _id field for a new user
|
// make sure we set the _id field for a new user
|
||||||
// Also if this is a new user, associate groups with them
|
// Also if this is a new user, associate groups with them
|
||||||
let groupPromises = []
|
let groupPromises = []
|
||||||
if (!_id) {
|
if (!_id) {
|
||||||
_id = builtUser._id!
|
_id = builtUser._id!
|
||||||
|
|
||||||
if (userGroups.length > 0) {
|
if (userGroups.length > 0) {
|
||||||
for (let groupId of userGroups) {
|
for (let groupId of userGroups) {
|
||||||
groupPromises.push(pro.groups.addUsers(groupId, [_id]))
|
groupPromises.push(pro.groups.addUsers(groupId, [_id]))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// save the user to db
|
// save the user to db
|
||||||
let response = await db.put(builtUser)
|
let response = await db.put(builtUser)
|
||||||
builtUser._rev = response.rev
|
builtUser._rev = response.rev
|
||||||
|
|
||||||
await eventHelpers.handleSaveEvents(builtUser, dbUser)
|
await eventHelpers.handleSaveEvents(builtUser, dbUser)
|
||||||
await platform.users.addUser(tenantId, builtUser._id!, builtUser.email)
|
await platform.users.addUser(tenantId, builtUser._id!, builtUser.email)
|
||||||
await cache.user.invalidateUser(response.id)
|
await cache.user.invalidateUser(response.id)
|
||||||
|
|
||||||
await Promise.all(groupPromises)
|
await Promise.all(groupPromises)
|
||||||
|
|
||||||
// finally returned the saved user from the db
|
// finally returned the saved user from the db
|
||||||
return db.get(builtUser._id!)
|
return db.get(builtUser._id!)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.status === 409) {
|
if (err.status === 409) {
|
||||||
throw "User exists already"
|
throw "User exists already"
|
||||||
} else {
|
} else {
|
||||||
throw err
|
throw err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getExistingTenantUsers = async (emails: string[]): Promise<User[]> => {
|
const getExistingTenantUsers = async (emails: string[]): Promise<User[]> => {
|
||||||
|
@ -374,7 +380,7 @@ const searchExistingEmails = async (emails: string[]) => {
|
||||||
export const bulkCreate = async (
|
export const bulkCreate = async (
|
||||||
newUsersRequested: User[],
|
newUsersRequested: User[],
|
||||||
groups: string[]
|
groups: string[]
|
||||||
): Promise<BulkUserResponse["created"]> => {
|
): Promise<BulkUserCreated> => {
|
||||||
const tenantId = tenancy.getTenantId()
|
const tenantId = tenancy.getTenantId()
|
||||||
|
|
||||||
let usersToSave: any[] = []
|
let usersToSave: any[] = []
|
||||||
|
@ -402,54 +408,56 @@ export const bulkCreate = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
const account = await accountSdk.api.getAccountByTenantId(tenantId)
|
const account = await accountSdk.api.getAccountByTenantId(tenantId)
|
||||||
// create the promises array that will be called by bulkDocs
|
return pro.quotas.addUsers(newUsers.length, async () => {
|
||||||
newUsers.forEach((user: any) => {
|
// create the promises array that will be called by bulkDocs
|
||||||
usersToSave.push(
|
newUsers.forEach((user: any) => {
|
||||||
buildUser(
|
usersToSave.push(
|
||||||
user,
|
buildUser(
|
||||||
{
|
user,
|
||||||
hashPassword: true,
|
{
|
||||||
requirePassword: user.requirePassword,
|
hashPassword: true,
|
||||||
},
|
requirePassword: user.requirePassword,
|
||||||
tenantId,
|
},
|
||||||
undefined, // no dbUser
|
tenantId,
|
||||||
account
|
undefined, // no dbUser
|
||||||
|
account
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
})
|
||||||
})
|
|
||||||
|
|
||||||
const usersToBulkSave = await Promise.all(usersToSave)
|
const usersToBulkSave = await Promise.all(usersToSave)
|
||||||
await usersCore.bulkUpdateGlobalUsers(usersToBulkSave)
|
await usersCore.bulkUpdateGlobalUsers(usersToBulkSave)
|
||||||
|
|
||||||
// Post-processing of bulk added users, e.g. events and cache operations
|
// Post-processing of bulk added users, e.g. events and cache operations
|
||||||
for (const user of usersToBulkSave) {
|
for (const user of usersToBulkSave) {
|
||||||
// TODO: Refactor to bulk insert users into the info db
|
// TODO: Refactor to bulk insert users into the info db
|
||||||
// instead of relying on looping tenant creation
|
// instead of relying on looping tenant creation
|
||||||
await platform.users.addUser(tenantId, user._id, user.email)
|
await platform.users.addUser(tenantId, user._id, user.email)
|
||||||
await eventHelpers.handleSaveEvents(user, undefined)
|
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 {
|
return {
|
||||||
_id: user._id,
|
successful: saved,
|
||||||
email: user.email,
|
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 (
|
export const bulkDelete = async (
|
||||||
userIds: string[]
|
userIds: string[]
|
||||||
): Promise<BulkUserResponse["deleted"]> => {
|
): Promise<BulkUserDeleted> => {
|
||||||
const db = tenancy.getGlobalDB()
|
const db = tenancy.getGlobalDB()
|
||||||
|
|
||||||
const response: BulkUserResponse["deleted"] = {
|
const response: BulkUserDeleted = {
|
||||||
successful: [],
|
successful: [],
|
||||||
unsuccessful: [],
|
unsuccessful: [],
|
||||||
}
|
}
|
||||||
|
@ -511,6 +519,8 @@ export const bulkDelete = async (
|
||||||
_deleted: true,
|
_deleted: true,
|
||||||
}))
|
}))
|
||||||
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
|
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
|
||||||
|
|
||||||
|
await pro.quotas.removeUsers(toDelete.length)
|
||||||
for (let user of usersToDelete) {
|
for (let user of usersToDelete) {
|
||||||
await bulkDeleteProcessing(user)
|
await bulkDeleteProcessing(user)
|
||||||
}
|
}
|
||||||
|
@ -540,6 +550,8 @@ export const bulkDelete = async (
|
||||||
return response
|
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) => {
|
export const destroy = async (id: string) => {
|
||||||
const db = tenancy.getGlobalDB()
|
const db = tenancy.getGlobalDB()
|
||||||
const dbUser = (await db.get(id)) as User
|
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 db.remove(userId, dbUser._rev)
|
||||||
|
|
||||||
|
await pro.quotas.removeUsers(1)
|
||||||
await eventHelpers.handleDeleteEvents(dbUser)
|
await eventHelpers.handleDeleteEvents(dbUser)
|
||||||
await cache.user.invalidateUser(userId)
|
await cache.user.invalidateUser(userId)
|
||||||
await sessions.invalidateSessions(userId, { reason: "deletion" })
|
await sessions.invalidateSessions(userId, { reason: "deletion" })
|
||||||
|
|
|
@ -62,8 +62,8 @@ function createSMTPTransport(config?: SMTPInnerConfig) {
|
||||||
host: "smtp.ethereal.email",
|
host: "smtp.ethereal.email",
|
||||||
secure: false,
|
secure: false,
|
||||||
auth: {
|
auth: {
|
||||||
user: "wyatt.zulauf29@ethereal.email",
|
user: "seamus99@ethereal.email",
|
||||||
pass: "tEwDtHBWWxusVWAPfa",
|
pass: "5ghVteZAqj6jkKJF9R",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -88,6 +88,6 @@ if [ -d "../account-portal" ]; then
|
||||||
echo "Linking bbui to account-portal"
|
echo "Linking bbui to account-portal"
|
||||||
yarn link "@budibase/bbui"
|
yarn link "@budibase/bbui"
|
||||||
|
|
||||||
echo "Linking frontend-core to account-portal"
|
echo "Linking frontend-core to account-portal"
|
||||||
yarn link "@budibase/frontend-core"
|
yarn link "@budibase/frontend-core"
|
||||||
fi
|
fi
|
||||||
|
|
Loading…
Reference in New Issue