diff --git a/lerna.json b/lerna.json index dde9cf03a0..3afc2c60e9 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "3.2.32", + "version": "3.2.33", "npmClient": "yarn", "concurrency": 20, "command": { diff --git a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte index 88e034a96b..37abd7f1eb 100644 --- a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte @@ -236,13 +236,13 @@ } if (!role) { - await groups.actions.removeApp(target._id, prodAppId) + await groups.removeApp(target._id, prodAppId) } else { - await groups.actions.addApp(target._id, prodAppId, role) + await groups.addApp(target._id, prodAppId, role) } await usersFetch.refresh() - await groups.actions.init() + await groups.init() } const onUpdateGroup = async (group, role) => { @@ -268,7 +268,7 @@ if (!group.roles) { return false } - return groups.actions.getGroupAppIds(group).includes(appId) + return groups.getGroupAppIds(group).includes(appId) }) } @@ -299,7 +299,7 @@ role: group?.builder?.apps.includes(prodAppId) ? Constants.Roles.CREATOR : group.roles?.[ - groups.actions.getGroupAppIds(group).find(x => x === prodAppId) + groups.getGroupAppIds(group).find(x => x === prodAppId) ], } } @@ -485,12 +485,12 @@ } const removeGroupAppBuilder = async groupId => { - await groups.actions.removeGroupAppBuilder(groupId, prodAppId) + await groups.removeGroupAppBuilder(groupId, prodAppId) } const initSidePanel = async sidePaneOpen => { if (sidePaneOpen === true) { - await groups.actions.init() + await groups.init() } loaded = true } diff --git a/packages/builder/src/pages/builder/apps/index.svelte b/packages/builder/src/pages/builder/apps/index.svelte index 8bf96d0240..e106d0dd68 100644 --- a/packages/builder/src/pages/builder/apps/index.svelte +++ b/packages/builder/src/pages/builder/apps/index.svelte @@ -53,7 +53,7 @@ } if (!Object.keys(user?.roles).length && user?.userGroups) { return userGroups.find(group => { - return groups.actions + return groups .getGroupAppIds(group) .map(role => appsStore.extractAppId(role)) .includes(app.appId) @@ -86,7 +86,7 @@ try { await organisation.init() await appsStore.load() - await groups.actions.init() + await groups.init() } catch (error) { notifications.error("Error loading apps") } diff --git a/packages/builder/src/pages/builder/portal/apps/_layout.svelte b/packages/builder/src/pages/builder/portal/apps/_layout.svelte index 560c1394fb..0a3b02f30f 100644 --- a/packages/builder/src/pages/builder/portal/apps/_layout.svelte +++ b/packages/builder/src/pages/builder/portal/apps/_layout.svelte @@ -24,7 +24,7 @@ promises.push(templates.load()) } - promises.push(groups.actions.init()) + promises.push(groups.init()) // Always load latest await Promise.all(promises) diff --git a/packages/builder/src/pages/builder/portal/users/groups/[groupId].svelte b/packages/builder/src/pages/builder/portal/users/groups/[groupId].svelte index 312d87f873..58fd1d93cb 100644 --- a/packages/builder/src/pages/builder/portal/users/groups/[groupId].svelte +++ b/packages/builder/src/pages/builder/portal/users/groups/[groupId].svelte @@ -53,9 +53,7 @@ $: readonly = !isAdmin || isScimGroup $: groupApps = $appsStore.apps .filter(app => - groups.actions - .getGroupAppIds(group) - .includes(appsStore.getProdAppID(app.devId)) + groups.getGroupAppIds(group).includes(appsStore.getProdAppID(app.devId)) ) .map(app => ({ ...app, @@ -72,7 +70,7 @@ async function deleteGroup() { try { - await groups.actions.delete(group) + await groups.delete(group) notifications.success("User group deleted successfully") $goto("./") } catch (error) { @@ -82,7 +80,7 @@ async function saveGroup(group) { try { - await groups.actions.save(group) + await groups.save(group) } catch (error) { if (error.message) { notifications.error(error.message) @@ -93,7 +91,7 @@ } const removeApp = async app => { - await groups.actions.removeApp(groupId, appsStore.getProdAppID(app.devId)) + await groups.removeApp(groupId, appsStore.getProdAppID(app.devId)) } setContext("roles", { updateRole: () => {}, @@ -102,7 +100,7 @@ onMount(async () => { try { - await Promise.all([groups.actions.init(), roles.fetch()]) + await Promise.all([groups.init(), roles.fetch()]) loaded = true } catch (error) { notifications.error("Error fetching user group data") diff --git a/packages/builder/src/pages/builder/portal/users/groups/_components/AppAddModal.svelte b/packages/builder/src/pages/builder/portal/users/groups/_components/AppAddModal.svelte index 75600c6fc0..88b8b4657b 100644 --- a/packages/builder/src/pages/builder/portal/users/groups/_components/AppAddModal.svelte +++ b/packages/builder/src/pages/builder/portal/users/groups/_components/AppAddModal.svelte @@ -23,7 +23,7 @@ return keepOpen } else { - await groups.actions.addApp(group._id, prodAppId, selectedRoleId) + await groups.addApp(group._id, prodAppId, selectedRoleId) } } diff --git a/packages/builder/src/pages/builder/portal/users/groups/_components/EditUserPicker.svelte b/packages/builder/src/pages/builder/portal/users/groups/_components/EditUserPicker.svelte index 1e7e15d1b4..d360de3850 100644 --- a/packages/builder/src/pages/builder/portal/users/groups/_components/EditUserPicker.svelte +++ b/packages/builder/src/pages/builder/portal/users/groups/_components/EditUserPicker.svelte @@ -50,11 +50,11 @@ selected={group.users?.map(user => user._id)} list={$users.data} on:select={async e => { - await groups.actions.addUser(groupId, e.detail) + await groups.addUser(groupId, e.detail) onUsersUpdated() }} on:deselect={async e => { - await groups.actions.removeUser(groupId, e.detail) + await groups.removeUser(groupId, e.detail) onUsersUpdated() }} /> diff --git a/packages/builder/src/pages/builder/portal/users/groups/index.svelte b/packages/builder/src/pages/builder/portal/users/groups/index.svelte index 77b0dc5734..9982f85352 100644 --- a/packages/builder/src/pages/builder/portal/users/groups/index.svelte +++ b/packages/builder/src/pages/builder/portal/users/groups/index.svelte @@ -60,7 +60,7 @@ async function saveGroup(group) { try { - group = await groups.actions.save(group) + group = await groups.save(group) $goto(`./${group._id}`) notifications.success(`User group created successfully`) } catch (error) { @@ -83,7 +83,7 @@ try { // always load latest await licensing.init() - await groups.actions.init() + await groups.init() } catch (error) { notifications.error("Error getting user groups") } diff --git a/packages/builder/src/pages/builder/portal/users/users/[userId].svelte b/packages/builder/src/pages/builder/portal/users/users/[userId].svelte index 94fe3081c3..6c480d9ef8 100644 --- a/packages/builder/src/pages/builder/portal/users/users/[userId].svelte +++ b/packages/builder/src/pages/builder/portal/users/users/[userId].svelte @@ -87,6 +87,7 @@ let popover let user, tenantOwner let loaded = false + let userFieldsToUpdate = {} $: internalGroups = $groups?.filter(g => !g?.scimInfo?.isSync) @@ -164,40 +165,45 @@ return label } - async function updateUserFirstName(evt) { + async function saveUser() { try { - await users.save({ ...user, firstName: evt.target.value }) + await users.save({ ...user, ...userFieldsToUpdate }) + userFieldsToUpdate = {} await fetchUser() } catch (error) { notifications.error("Error updating user") } } + async function updateUserFirstName(evt) { + userFieldsToUpdate.firstName = evt.target.value + } + async function updateUserLastName(evt) { - try { - await users.save({ ...user, lastName: evt.target.value }) - await fetchUser() - } catch (error) { - notifications.error("Error updating user") - } + userFieldsToUpdate.lastName = evt.target.value } async function updateUserRole({ detail }) { + let flags = {} if (detail === Constants.BudibaseRoles.Developer) { - toggleFlags({ admin: { global: false }, builder: { global: true } }) + flags = { admin: { global: false }, builder: { global: true } } } else if (detail === Constants.BudibaseRoles.Admin) { - toggleFlags({ admin: { global: true }, builder: { global: true } }) + flags = { admin: { global: true }, builder: { global: true } } } else if (detail === Constants.BudibaseRoles.AppUser) { - toggleFlags({ admin: { global: false }, builder: { global: false } }) + flags = { admin: { global: false }, builder: { global: false } } } else if (detail === Constants.BudibaseRoles.Creator) { - toggleFlags({ + flags = { admin: { global: false }, builder: { global: false, creator: true, apps: user?.builder?.apps || [], }, - }) + } + } + userFieldsToUpdate = { + ...userFieldsToUpdate, + ...flags, } } @@ -209,22 +215,13 @@ tenantOwner = await users.getAccountHolder() } - async function toggleFlags(detail) { - try { - await users.save({ ...user, ...detail }) - await fetchUser() - } catch (error) { - notifications.error("Error updating user") - } - } - const addGroup = async groupId => { - await groups.actions.addUser(groupId, userId) + await groups.addUser(groupId, userId) await fetchUser() } const removeGroup = async groupId => { - await groups.actions.removeUser(groupId, userId) + await groups.removeUser(groupId, userId) await fetchUser() } @@ -234,7 +231,7 @@ onMount(async () => { try { - await Promise.all([fetchUser(), groups.actions.init(), roles.fetch()]) + await Promise.all([fetchUser(), groups.init(), roles.fetch()]) loaded = true } catch (error) { notifications.error("Error getting user groups") @@ -296,7 +293,7 @@
@@ -304,7 +301,7 @@
@@ -325,6 +322,13 @@ {/if} +
+ +
{#if $licensing.groupsEnabled} diff --git a/packages/builder/src/pages/builder/portal/users/users/index.svelte b/packages/builder/src/pages/builder/portal/users/users/index.svelte index 80772ccbee..97120c55d4 100644 --- a/packages/builder/src/pages/builder/portal/users/users/index.svelte +++ b/packages/builder/src/pages/builder/portal/users/users/index.svelte @@ -247,7 +247,7 @@ try { bulkSaveResponse = await users.create(await removingDuplicities(userData)) notifications.success("Successfully created user") - await groups.actions.init() + await groups.init() passwordModal.show() await fetch.refresh() } catch (error) { @@ -317,7 +317,7 @@ onMount(async () => { try { - await groups.actions.init() + await groups.init() groupsLoaded = true } catch (error) { notifications.error("Error fetching user group data") diff --git a/packages/builder/src/stores/builder/published.js b/packages/builder/src/stores/builder/published.ts similarity index 53% rename from packages/builder/src/stores/builder/published.js rename to packages/builder/src/stores/builder/published.ts index a59352fb22..c38f3bb718 100644 --- a/packages/builder/src/stores/builder/published.js +++ b/packages/builder/src/stores/builder/published.ts @@ -1,13 +1,16 @@ import { appStore } from "./app" import { appsStore } from "@/stores/portal/apps" import { deploymentStore } from "./deployments" -import { derived } from "svelte/store" +import { derived, type Readable } from "svelte/store" +import { DeploymentProgressResponse, DeploymentStatus } from "@budibase/types" -export const appPublished = derived( +export const appPublished: Readable = derived( [appStore, appsStore, deploymentStore], ([$appStore, $appsStore, $deploymentStore]) => { const app = $appsStore.apps.find(app => app.devId === $appStore.appId) - const deployments = $deploymentStore.filter(x => x.status === "SUCCESS") + const deployments = $deploymentStore.filter( + (x: DeploymentProgressResponse) => x.status === DeploymentStatus.SUCCESS + ) return app?.status === "published" && deployments.length > 0 } ) diff --git a/packages/builder/src/stores/builder/roles.js b/packages/builder/src/stores/builder/roles.js deleted file mode 100644 index e718545f14..0000000000 --- a/packages/builder/src/stores/builder/roles.js +++ /dev/null @@ -1,88 +0,0 @@ -import { derived, writable, get } from "svelte/store" -import { API } from "@/api" -import { RoleUtils } from "@budibase/frontend-core" - -export function createRolesStore() { - const store = writable([]) - const enriched = derived(store, $store => { - return $store.map(role => ({ - ...role, - - // Ensure we have new metadata for all roles - uiMetadata: { - displayName: role.uiMetadata?.displayName || role.name, - color: - role.uiMetadata?.color || "var(--spectrum-global-color-magenta-400)", - description: role.uiMetadata?.description || "Custom role", - }, - })) - }) - - function setRoles(roles) { - store.set( - roles.sort((a, b) => { - const priorityA = RoleUtils.getRolePriority(a._id) - const priorityB = RoleUtils.getRolePriority(b._id) - if (priorityA !== priorityB) { - return priorityA > priorityB ? -1 : 1 - } - const nameA = a.uiMetadata?.displayName || a.name - const nameB = b.uiMetadata?.displayName || b.name - return nameA < nameB ? -1 : 1 - }) - ) - } - - const actions = { - fetch: async () => { - const roles = await API.getRoles() - setRoles(roles) - }, - fetchByAppId: async appId => { - const { roles } = await API.getRolesForApp(appId) - setRoles(roles) - }, - delete: async role => { - await API.deleteRole(role._id, role._rev) - await actions.fetch() - }, - save: async role => { - const savedRole = await API.saveRole(role) - await actions.fetch() - return savedRole - }, - replace: (roleId, role) => { - // Handles external updates of roles - if (!roleId) { - return - } - - // Handle deletion - if (!role) { - store.update(state => state.filter(x => x._id !== roleId)) - return - } - - // Add new role - const index = get(store).findIndex(x => x._id === role._id) - if (index === -1) { - store.update(state => [...state, role]) - } - - // Update existing role - else if (role) { - store.update(state => { - state[index] = role - return [...state] - }) - } - }, - } - - return { - subscribe: enriched.subscribe, - ...actions, - } -} - -export const roles = createRolesStore() diff --git a/packages/builder/src/stores/builder/roles.ts b/packages/builder/src/stores/builder/roles.ts new file mode 100644 index 0000000000..732f50d6be --- /dev/null +++ b/packages/builder/src/stores/builder/roles.ts @@ -0,0 +1,94 @@ +import { derived, get, type Writable } from "svelte/store" +import { API } from "@/api" +import { RoleUtils } from "@budibase/frontend-core" +import { DerivedBudiStore } from "../BudiStore" +import { Role } from "@budibase/types" + +export class RoleStore extends DerivedBudiStore { + constructor() { + const makeDerivedStore = (store: Writable) => + derived(store, $store => { + return $store.map((role: Role) => ({ + ...role, + // Ensure we have new metadata for all roles + uiMetadata: { + displayName: role.uiMetadata?.displayName || role.name, + color: + role.uiMetadata?.color || + "var(--spectrum-global-color-magenta-400)", + description: role.uiMetadata?.description || "Custom role", + }, + })) + }) + + super([], makeDerivedStore) + } + + setRoles = (roles: Role[]) => { + this.set( + roles.sort((a, b) => { + // Ensure we have valid IDs for priority comparison + const priorityA = RoleUtils.getRolePriority(a._id) + const priorityB = RoleUtils.getRolePriority(b._id) + if (priorityA !== priorityB) { + return priorityA > priorityB ? -1 : 1 + } + const nameA = a.uiMetadata?.displayName || a.name + const nameB = b.uiMetadata?.displayName || b.name + return nameA < nameB ? -1 : 1 + }) + ) + } + + fetch = async () => { + const roles = await API.getRoles() + this.setRoles(roles) + } + + fetchByAppId = async (appId: string) => { + const { roles } = await API.getRolesForApp(appId) + this.setRoles(roles) + } + + delete = async (role: Role) => { + if (!role._id || !role._rev) { + return + } + await API.deleteRole(role._id, role._rev) + await this.fetch() + } + + save = async (role: Role) => { + const savedRole = await API.saveRole(role) + await this.fetch() + return savedRole + } + + replace = (roleId: string, role?: Role) => { + // Handles external updates of roles + if (!roleId) { + return + } + + // Handle deletion + if (!role) { + this.update(state => state.filter(x => x._id !== roleId)) + return + } + + // Add new role + const index = get(this).findIndex(x => x._id === role._id) + if (index === -1) { + this.update(state => [...state, role]) + } + // Update existing role + else if (role) { + this.update(state => { + state[index] = role + return [...state] + }) + } + } +} + +export const roles = new RoleStore() diff --git a/packages/builder/src/stores/portal/groups.js b/packages/builder/src/stores/portal/groups.js deleted file mode 100644 index 408fb4189a..0000000000 --- a/packages/builder/src/stores/portal/groups.js +++ /dev/null @@ -1,103 +0,0 @@ -import { writable, get } from "svelte/store" -import { API } from "@/api" -import { licensing } from "@/stores/portal" - -export function createGroupsStore() { - const store = writable([]) - - const updateStore = group => { - store.update(state => { - const currentIdx = state.findIndex(gr => gr._id === group._id) - if (currentIdx >= 0) { - state.splice(currentIdx, 1, group) - } else { - state.push(group) - } - return state - }) - } - - const getGroup = async groupId => { - const group = await API.getGroup(groupId) - updateStore(group) - } - - const actions = { - init: async () => { - // only init if there is a groups license, just to be sure but the feature will be blocked - // on the backend anyway - if (get(licensing).groupsEnabled) { - const groups = await API.getGroups() - store.set(groups.data) - } - }, - - get: getGroup, - - save: async group => { - const { ...dataToSave } = group - delete dataToSave.scimInfo - delete dataToSave.userGroups - const response = await API.saveGroup(dataToSave) - group._id = response._id - group._rev = response._rev - updateStore(group) - return group - }, - - delete: async group => { - await API.deleteGroup(group._id, group._rev) - store.update(state => { - state = state.filter(state => state._id !== group._id) - return state - }) - }, - - addUser: async (groupId, userId) => { - await API.addUsersToGroup(groupId, userId) - // refresh the group enrichment - await getGroup(groupId) - }, - - removeUser: async (groupId, userId) => { - await API.removeUsersFromGroup(groupId, userId) - // refresh the group enrichment - await getGroup(groupId) - }, - - addApp: async (groupId, appId, roleId) => { - await API.addAppsToGroup(groupId, [{ appId, roleId }]) - // refresh the group roles - await getGroup(groupId) - }, - - removeApp: async (groupId, appId) => { - await API.removeAppsFromGroup(groupId, [{ appId }]) - // refresh the group roles - await getGroup(groupId) - }, - - getGroupAppIds: group => { - let groupAppIds = Object.keys(group?.roles || {}) - if (group?.builder?.apps) { - groupAppIds = groupAppIds.concat(group.builder.apps) - } - return groupAppIds - }, - - addGroupAppBuilder: async (groupId, appId) => { - return await API.addGroupAppBuilder(groupId, appId) - }, - - removeGroupAppBuilder: async (groupId, appId) => { - return await API.removeGroupAppBuilder(groupId, appId) - }, - } - - return { - subscribe: store.subscribe, - actions, - } -} - -export const groups = createGroupsStore() diff --git a/packages/builder/src/stores/portal/groups.ts b/packages/builder/src/stores/portal/groups.ts new file mode 100644 index 0000000000..028f300d2c --- /dev/null +++ b/packages/builder/src/stores/portal/groups.ts @@ -0,0 +1,96 @@ +import { get } from "svelte/store" +import { API } from "@/api" +import { licensing } from "@/stores/portal" +import { UserGroup } from "@budibase/types" +import { BudiStore } from "../BudiStore" + +class GroupStore extends BudiStore { + constructor() { + super([]) + } + + updateStore = (group: UserGroup) => { + this.update(state => { + const currentIdx = state.findIndex(gr => gr._id === group._id) + if (currentIdx >= 0) { + state.splice(currentIdx, 1, group) + } else { + state.push(group) + } + return state + }) + } + + async init() { + // Only init if there is a groups license, just to be sure but the feature will be blocked + // on the backend anyway + if (get(licensing).groupsEnabled) { + const groups = await API.getGroups() + this.set(groups) + } + } + + private async refreshGroup(groupId: string) { + const group = await API.getGroup(groupId) + this.updateStore(group) + } + + async save(group: UserGroup) { + const { ...dataToSave } = group + delete dataToSave.scimInfo + const response = await API.saveGroup(dataToSave) + group._id = response._id + group._rev = response._rev + this.updateStore(group) + return group + } + + async delete(group: UserGroup) { + await API.deleteGroup(group._id!, group._rev!) + this.update(groups => { + const index = groups.findIndex(g => g._id === group._id) + if (index !== -1) { + groups.splice(index, 1) + } + return groups + }) + } + + async addUser(groupId: string, userId: string) { + await API.addUsersToGroup(groupId, [userId]) + await this.refreshGroup(groupId) + } + + async removeUser(groupId: string, userId: string) { + await API.removeUsersFromGroup(groupId, [userId]) + await this.refreshGroup(groupId) + } + + async addApp(groupId: string, appId: string, roleId: string) { + await API.addAppsToGroup(groupId, [{ appId, roleId }]) + await this.refreshGroup(groupId) + } + + async removeApp(groupId: string, appId: string) { + await API.removeAppsFromGroup(groupId, [{ appId }]) + await this.refreshGroup(groupId) + } + + getGroupAppIds(group: UserGroup) { + let groupAppIds = Object.keys(group?.roles || {}) + if (group?.builder?.apps) { + groupAppIds = groupAppIds.concat(group.builder.apps) + } + return groupAppIds + } + + async addGroupAppBuilder(groupId: string, appId: string) { + return await API.addGroupAppBuilder(groupId, appId) + } + + async removeGroupAppBuilder(groupId: string, appId: string) { + return await API.removeGroupAppBuilder(groupId, appId) + } +} + +export const groups = new GroupStore() diff --git a/packages/builder/src/stores/portal/licensing.js b/packages/builder/src/stores/portal/licensing.js deleted file mode 100644 index afc3ea1628..0000000000 --- a/packages/builder/src/stores/portal/licensing.js +++ /dev/null @@ -1,279 +0,0 @@ -import { writable, get } from "svelte/store" -import { API } from "@/api" -import { auth, admin } from "@/stores/portal" -import { Constants } from "@budibase/frontend-core" -import { StripeStatus } from "@/components/portal/licensing/constants" -import { PlanModel } from "@budibase/types" - -const UNLIMITED = -1 - -export const createLicensingStore = () => { - const DEFAULT = { - // navigation - goToUpgradePage: () => {}, - goToPricingPage: () => {}, - // the top level license - license: undefined, - isFreePlan: true, - isEnterprisePlan: true, - isBusinessPlan: true, - // features - groupsEnabled: false, - backupsEnabled: false, - brandingEnabled: false, - scimEnabled: false, - environmentVariablesEnabled: false, - budibaseAIEnabled: false, - customAIConfigsEnabled: false, - auditLogsEnabled: false, - // the currently used quotas from the db - quotaUsage: undefined, - // derived quota metrics for percentages used - usageMetrics: undefined, - // quota reset - quotaResetDaysRemaining: undefined, - quotaResetDate: undefined, - // failed payments - accountPastDue: undefined, - pastDueEndDate: undefined, - pastDueDaysRemaining: undefined, - accountDowngraded: undefined, - // user limits - userCount: undefined, - userLimit: undefined, - userLimitReached: false, - errUserLimit: false, - } - - const oneDayInMilliseconds = 86400000 - - const store = writable(DEFAULT) - - function usersLimitReached(userCount, userLimit) { - if (userLimit === UNLIMITED) { - return false - } - return userCount >= userLimit - } - - function usersLimitExceeded(userCount, userLimit) { - if (userLimit === UNLIMITED) { - return false - } - return userCount > userLimit - } - - async function isCloud() { - let adminStore = get(admin) - if (!adminStore.loaded) { - await admin.init() - adminStore = get(admin) - } - return adminStore.cloud - } - - const actions = { - init: async () => { - actions.setNavigation() - actions.setLicense() - await actions.setQuotaUsage() - }, - setNavigation: () => { - const adminStore = get(admin) - const authStore = get(auth) - - const upgradeUrl = authStore?.user?.accountPortalAccess - ? `${adminStore.accountPortalUrl}/portal/upgrade` - : "/builder/portal/account/upgrade" - - const goToUpgradePage = () => { - window.location.href = upgradeUrl - } - const goToPricingPage = () => { - window.open("https://budibase.com/pricing/", "_blank") - } - store.update(state => { - return { - ...state, - goToUpgradePage, - goToPricingPage, - } - }) - }, - setLicense: () => { - const license = get(auth).user.license - const planType = license?.plan.type - const isEnterprisePlan = planType === Constants.PlanType.ENTERPRISE - const isFreePlan = planType === Constants.PlanType.FREE - const isBusinessPlan = planType === Constants.PlanType.BUSINESS - const isEnterpriseTrial = - planType === Constants.PlanType.ENTERPRISE_BASIC_TRIAL - const groupsEnabled = license.features.includes( - Constants.Features.USER_GROUPS - ) - const backupsEnabled = license.features.includes( - Constants.Features.APP_BACKUPS - ) - const scimEnabled = license.features.includes(Constants.Features.SCIM) - const environmentVariablesEnabled = license.features.includes( - Constants.Features.ENVIRONMENT_VARIABLES - ) - const enforceableSSO = license.features.includes( - Constants.Features.ENFORCEABLE_SSO - ) - const brandingEnabled = license.features.includes( - Constants.Features.BRANDING - ) - const auditLogsEnabled = license.features.includes( - Constants.Features.AUDIT_LOGS - ) - const syncAutomationsEnabled = license.features.includes( - Constants.Features.SYNC_AUTOMATIONS - ) - const triggerAutomationRunEnabled = license.features.includes( - Constants.Features.TRIGGER_AUTOMATION_RUN - ) - const perAppBuildersEnabled = license.features.includes( - Constants.Features.APP_BUILDERS - ) - const budibaseAIEnabled = license.features.includes( - Constants.Features.BUDIBASE_AI - ) - const customAIConfigsEnabled = license.features.includes( - Constants.Features.AI_CUSTOM_CONFIGS - ) - store.update(state => { - return { - ...state, - license, - isEnterprisePlan, - isFreePlan, - isBusinessPlan, - isEnterpriseTrial, - groupsEnabled, - backupsEnabled, - brandingEnabled, - budibaseAIEnabled, - customAIConfigsEnabled, - scimEnabled, - environmentVariablesEnabled, - auditLogsEnabled, - enforceableSSO, - syncAutomationsEnabled, - triggerAutomationRunEnabled, - perAppBuildersEnabled, - } - }) - }, - setQuotaUsage: async () => { - const quotaUsage = await API.getQuotaUsage() - store.update(state => { - return { - ...state, - quotaUsage, - } - }) - await actions.setUsageMetrics() - }, - usersLimitReached: userCount => { - return usersLimitReached(userCount, get(store).userLimit) - }, - usersLimitExceeded(userCount) { - return usersLimitExceeded(userCount, get(store).userLimit) - }, - setUsageMetrics: async () => { - const usage = get(store).quotaUsage - const license = get(auth).user.license - const now = new Date() - - const getMetrics = (keys, license, quota) => { - if (!license || !quota || !keys) { - return {} - } - return keys.reduce((acc, key) => { - const quotaLimit = license[key].value - const quotaUsed = (quota[key] / quotaLimit) * 100 - acc[key] = quotaLimit > -1 ? Math.floor(quotaUsed) : -1 - return acc - }, {}) - } - const monthlyMetrics = getMetrics( - ["queries", "automations"], - license.quotas.usage.monthly, - usage.monthly.current - ) - const staticMetrics = getMetrics( - ["apps", "rows"], - license.quotas.usage.static, - usage.usageQuota - ) - - const getDaysBetween = (dateStart, dateEnd) => { - return dateEnd > dateStart - ? Math.round( - (dateEnd.getTime() - dateStart.getTime()) / oneDayInMilliseconds - ) - : 0 - } - - const quotaResetDate = new Date(usage.quotaReset) - const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate) - - const accountDowngraded = - license?.billing?.subscription?.downgradeAt && - license?.billing?.subscription?.downgradeAt <= now.getTime() && - license?.billing?.subscription?.status === StripeStatus.PAST_DUE && - license?.plan.type === Constants.PlanType.FREE - - const pastDueAtMilliseconds = license?.billing?.subscription?.pastDueAt - const downgradeAtMilliseconds = - license?.billing?.subscription?.downgradeAt - let pastDueDaysRemaining - let pastDueEndDate - - if (pastDueAtMilliseconds && downgradeAtMilliseconds) { - pastDueEndDate = new Date(downgradeAtMilliseconds) - pastDueDaysRemaining = getDaysBetween( - new Date(pastDueAtMilliseconds), - pastDueEndDate - ) - } - - const userQuota = license.quotas.usage.static.users - const userLimit = userQuota?.value - const userCount = usage.usageQuota.users - const userLimitReached = usersLimitReached(userCount, userLimit) - const userLimitExceeded = usersLimitExceeded(userCount, userLimit) - const isCloudAccount = await isCloud() - const errUserLimit = - isCloudAccount && - license.plan.model === PlanModel.PER_USER && - userLimitExceeded - - store.update(state => { - return { - ...state, - usageMetrics: { ...monthlyMetrics, ...staticMetrics }, - quotaResetDaysRemaining, - quotaResetDate, - accountDowngraded, - accountPastDue: pastDueAtMilliseconds != null, - pastDueEndDate, - pastDueDaysRemaining, - // user limits - userCount, - userLimit, - userLimitReached, - errUserLimit, - } - }) - }, - } - - return { - subscribe: store.subscribe, - ...actions, - } -} - -export const licensing = createLicensingStore() diff --git a/packages/builder/src/stores/portal/licensing.ts b/packages/builder/src/stores/portal/licensing.ts new file mode 100644 index 0000000000..0b56e14005 --- /dev/null +++ b/packages/builder/src/stores/portal/licensing.ts @@ -0,0 +1,305 @@ +import { get } from "svelte/store" +import { API } from "@/api" +import { auth, admin } from "@/stores/portal" +import { Constants } from "@budibase/frontend-core" +import { StripeStatus } from "@/components/portal/licensing/constants" +import { + License, + MonthlyQuotaName, + PlanModel, + QuotaUsage, + StaticQuotaName, +} from "@budibase/types" +import { BudiStore } from "../BudiStore" + +const UNLIMITED = -1 +const ONE_DAY_MILLIS = 86400000 + +type MonthlyMetrics = { [key in MonthlyQuotaName]?: number } +type StaticMetrics = { [key in StaticQuotaName]?: number } +type UsageMetrics = MonthlyMetrics & StaticMetrics + +interface LicensingState { + goToUpgradePage: () => void + goToPricingPage: () => void + // the top level license + license?: License + isFreePlan: boolean + isEnterprisePlan: boolean + isBusinessPlan: boolean + // features + groupsEnabled: boolean + backupsEnabled: boolean + brandingEnabled: boolean + scimEnabled: boolean + environmentVariablesEnabled: boolean + budibaseAIEnabled: boolean + customAIConfigsEnabled: boolean + auditLogsEnabled: boolean + // the currently used quotas from the db + quotaUsage?: QuotaUsage + // derived quota metrics for percentages used + usageMetrics?: UsageMetrics + // quota reset + quotaResetDaysRemaining?: number + quotaResetDate?: Date + // failed payments + accountPastDue: boolean + pastDueEndDate?: Date + pastDueDaysRemaining?: number + accountDowngraded: boolean + // user limits + userCount?: number + userLimit?: number + userLimitReached: boolean + errUserLimit: boolean +} + +class LicensingStore extends BudiStore { + constructor() { + super({ + // navigation + goToUpgradePage: () => {}, + goToPricingPage: () => {}, + // the top level license + license: undefined, + isFreePlan: true, + isEnterprisePlan: true, + isBusinessPlan: true, + // features + groupsEnabled: false, + backupsEnabled: false, + brandingEnabled: false, + scimEnabled: false, + environmentVariablesEnabled: false, + budibaseAIEnabled: false, + customAIConfigsEnabled: false, + auditLogsEnabled: false, + // the currently used quotas from the db + quotaUsage: undefined, + // derived quota metrics for percentages used + usageMetrics: undefined, + // quota reset + quotaResetDaysRemaining: undefined, + quotaResetDate: undefined, + // failed payments + accountPastDue: false, + pastDueEndDate: undefined, + pastDueDaysRemaining: undefined, + accountDowngraded: false, + // user limits + userCount: undefined, + userLimit: undefined, + userLimitReached: false, + errUserLimit: false, + }) + } + + usersLimitReached(userCount: number, userLimit = get(this.store).userLimit) { + if (userLimit === UNLIMITED || userLimit === undefined) { + return false + } + return userCount >= userLimit + } + + usersLimitExceeded(userCount: number, userLimit = get(this.store).userLimit) { + if (userLimit === UNLIMITED || userLimit === undefined) { + return false + } + return userCount > userLimit + } + + async isCloud() { + let adminStore = get(admin) + if (!adminStore.loaded) { + await admin.init() + adminStore = get(admin) + } + return adminStore.cloud + } + + async init() { + this.setNavigation() + this.setLicense() + await this.setQuotaUsage() + } + + setNavigation() { + const adminStore = get(admin) + const authStore = get(auth) + + const upgradeUrl = authStore?.user?.accountPortalAccess + ? `${adminStore.accountPortalUrl}/portal/upgrade` + : "/builder/portal/account/upgrade" + + const goToUpgradePage = () => { + window.location.href = upgradeUrl + } + const goToPricingPage = () => { + window.open("https://budibase.com/pricing/", "_blank") + } + this.update(state => { + return { + ...state, + goToUpgradePage, + goToPricingPage, + } + }) + } + + setLicense() { + const license = get(auth).user?.license + const planType = license?.plan.type + const features = license?.features || [] + const isEnterprisePlan = planType === Constants.PlanType.ENTERPRISE + const isFreePlan = planType === Constants.PlanType.FREE + const isBusinessPlan = planType === Constants.PlanType.BUSINESS + const isEnterpriseTrial = + planType === Constants.PlanType.ENTERPRISE_BASIC_TRIAL + const groupsEnabled = features.includes(Constants.Features.USER_GROUPS) + const backupsEnabled = features.includes(Constants.Features.APP_BACKUPS) + const scimEnabled = features.includes(Constants.Features.SCIM) + const environmentVariablesEnabled = features.includes( + Constants.Features.ENVIRONMENT_VARIABLES + ) + const enforceableSSO = features.includes(Constants.Features.ENFORCEABLE_SSO) + const brandingEnabled = features.includes(Constants.Features.BRANDING) + const auditLogsEnabled = features.includes(Constants.Features.AUDIT_LOGS) + const syncAutomationsEnabled = features.includes( + Constants.Features.SYNC_AUTOMATIONS + ) + const triggerAutomationRunEnabled = features.includes( + Constants.Features.TRIGGER_AUTOMATION_RUN + ) + const perAppBuildersEnabled = features.includes( + Constants.Features.APP_BUILDERS + ) + const budibaseAIEnabled = features.includes(Constants.Features.BUDIBASE_AI) + const customAIConfigsEnabled = features.includes( + Constants.Features.AI_CUSTOM_CONFIGS + ) + this.update(state => { + return { + ...state, + license, + isEnterprisePlan, + isFreePlan, + isBusinessPlan, + isEnterpriseTrial, + groupsEnabled, + backupsEnabled, + brandingEnabled, + budibaseAIEnabled, + customAIConfigsEnabled, + scimEnabled, + environmentVariablesEnabled, + auditLogsEnabled, + enforceableSSO, + syncAutomationsEnabled, + triggerAutomationRunEnabled, + perAppBuildersEnabled, + } + }) + } + + async setQuotaUsage() { + const quotaUsage = await API.getQuotaUsage() + this.update(state => { + return { + ...state, + quotaUsage, + } + }) + await this.setUsageMetrics() + } + + async setUsageMetrics() { + const usage = get(this.store).quotaUsage + const license = get(auth).user?.license + const now = new Date() + if (!license || !usage) { + return + } + + // Process monthly metrics + const monthlyMetrics = [ + MonthlyQuotaName.QUERIES, + MonthlyQuotaName.AUTOMATIONS, + ].reduce((acc: MonthlyMetrics, key) => { + const limit = license.quotas.usage.monthly[key].value + const used = (usage.monthly.current?.[key] || 0 / limit) * 100 + acc[key] = limit > -1 ? Math.floor(used) : -1 + return acc + }, {}) + + // Process static metrics + const staticMetrics = [StaticQuotaName.APPS, StaticQuotaName.ROWS].reduce( + (acc: StaticMetrics, key) => { + const limit = license.quotas.usage.static[key].value + const used = (usage.usageQuota[key] || 0 / limit) * 100 + acc[key] = limit > -1 ? Math.floor(used) : -1 + return acc + }, + {} + ) + + const getDaysBetween = (dateStart: Date, dateEnd: Date) => { + return dateEnd > dateStart + ? Math.round((dateEnd.getTime() - dateStart.getTime()) / ONE_DAY_MILLIS) + : 0 + } + + const quotaResetDate = new Date(usage.quotaReset) + const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate) + + const accountDowngraded = + !!license.billing?.subscription?.downgradeAt && + license.billing?.subscription?.downgradeAt <= now.getTime() && + license.billing?.subscription?.status === StripeStatus.PAST_DUE && + license.plan.type === Constants.PlanType.FREE + + const pastDueAtMilliseconds = license.billing?.subscription?.pastDueAt + const downgradeAtMilliseconds = license.billing?.subscription?.downgradeAt + let pastDueDaysRemaining: number + let pastDueEndDate: Date + + if (pastDueAtMilliseconds && downgradeAtMilliseconds) { + pastDueEndDate = new Date(downgradeAtMilliseconds) + pastDueDaysRemaining = getDaysBetween( + new Date(pastDueAtMilliseconds), + pastDueEndDate + ) + } + + const userQuota = license.quotas.usage.static.users + const userLimit = userQuota.value + const userCount = usage.usageQuota.users + const userLimitReached = this.usersLimitReached(userCount, userLimit) + const userLimitExceeded = this.usersLimitExceeded(userCount, userLimit) + const isCloudAccount = await this.isCloud() + const errUserLimit = + isCloudAccount && + license.plan.model === PlanModel.PER_USER && + userLimitExceeded + + this.update(state => { + return { + ...state, + usageMetrics: { ...monthlyMetrics, ...staticMetrics }, + quotaResetDaysRemaining, + quotaResetDate, + accountDowngraded, + accountPastDue: pastDueAtMilliseconds != null, + pastDueEndDate, + pastDueDaysRemaining, + // user limits + userCount, + userLimit, + userLimitReached, + errUserLimit, + } + }) + } +} + +export const licensing = new LicensingStore() diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js index 9d0bddcc92..16dc3c97e7 100644 --- a/packages/client/src/utils/buttonActions.js +++ b/packages/client/src/utils/buttonActions.js @@ -216,11 +216,11 @@ const deleteRowHandler = async action => { const triggerAutomationHandler = async action => { const { fields, notificationOverride, timeout } = action.parameters try { - const result = await API.triggerAutomation({ - automationId: action.parameters.automationId, + const result = await API.triggerAutomation( + action.parameters.automationId, fields, - timeout, - }) + timeout + ) // Value will exist if automation is synchronous, so return it. if (result.value) { diff --git a/packages/frontend-core/src/api/groups.ts b/packages/frontend-core/src/api/groups.ts index c09c5284ec..e6374f257c 100644 --- a/packages/frontend-core/src/api/groups.ts +++ b/packages/frontend-core/src/api/groups.ts @@ -1,4 +1,8 @@ -import { SearchUserGroupResponse, UserGroup } from "@budibase/types" +import { + SearchGroupResponse, + SearchUserGroupResponse, + UserGroup, +} from "@budibase/types" import { BaseAPIClient } from "./types" export interface GroupEndpoints { @@ -64,9 +68,10 @@ export const buildGroupsEndpoints = (API: BaseAPIClient): GroupEndpoints => { * Gets all the user groups */ getGroups: async () => { - return await API.get({ + const res = await API.get({ url: "/api/global/groups", }) + return res.data }, /** diff --git a/packages/types/src/api/web/global/self.ts b/packages/types/src/api/web/global/self.ts index 9e879e6c3c..517559d1ca 100644 --- a/packages/types/src/api/web/global/self.ts +++ b/packages/types/src/api/web/global/self.ts @@ -1,5 +1,6 @@ +import { License } from "../../../sdk" +import { Account, DevInfo, User } from "../../../documents" import { FeatureFlags } from "@budibase/types" -import { DevInfo, User } from "../../../documents" export interface GenerateAPIKeyRequest { userId?: string @@ -10,4 +11,9 @@ export interface FetchAPIKeyResponse extends DevInfo {} export interface GetGlobalSelfResponse extends User { flags?: FeatureFlags + account?: Account + license: License + budibaseAccess: boolean + accountPortalAccess: boolean + csrfToken: boolean } diff --git a/packages/worker/src/api/controllers/global/self.ts b/packages/worker/src/api/controllers/global/self.ts index eb704ccf02..3464bff88f 100644 --- a/packages/worker/src/api/controllers/global/self.ts +++ b/packages/worker/src/api/controllers/global/self.ts @@ -84,15 +84,15 @@ export async function fetchAPIKey(ctx: UserCtx) { } /** - * Add the attributes that are session based to the current user. + * */ -const addSessionAttributesToUser = (ctx: any) => { - ctx.body.account = ctx.user.account - ctx.body.license = ctx.user.license - ctx.body.budibaseAccess = !!ctx.user.budibaseAccess - ctx.body.accountPortalAccess = !!ctx.user.accountPortalAccess - ctx.body.csrfToken = ctx.user.csrfToken -} +const getUserSessionAttributes = (ctx: any) => ({ + account: ctx.user.account, + license: ctx.user.license, + budibaseAccess: !!ctx.user.budibaseAccess, + accountPortalAccess: !!ctx.user.accountPortalAccess, + csrfToken: ctx.user.csrfToken, +}) export async function getSelf(ctx: UserCtx) { if (!ctx.user) { @@ -108,12 +108,19 @@ export async function getSelf(ctx: UserCtx) { // get the main body of the user const user = await userSdk.db.getUser(userId) - ctx.body = await groups.enrichUserRolesFromGroups(user) + const enrichedUser = await groups.enrichUserRolesFromGroups(user) + + // add the attributes that are session based to the current user + const sessionAttributes = getUserSessionAttributes(ctx) // add the feature flags for this tenant - ctx.body.flags = await features.flags.fetch() + const flags = await features.flags.fetch() - addSessionAttributesToUser(ctx) + ctx.body = { + ...enrichedUser, + ...sessionAttributes, + flags, + } } export const syncAppFavourites = async (processedAppIds: string[]) => { diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index a028f4fd33..83f2f41b0e 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -72,12 +72,14 @@ export const save = async (ctx: UserCtx) => { const requestUser = ctx.request.body // Do not allow the account holder role to be changed - const accountMetadata = await users.getExistingAccounts([requestUser.email]) - if (accountMetadata?.length > 0) { - if ( - requestUser.admin?.global !== true || - requestUser.builder?.global !== true - ) { + if ( + requestUser.admin?.global !== true || + requestUser.builder?.global !== true + ) { + const accountMetadata = await users.getExistingAccounts([ + requestUser.email, + ]) + if (accountMetadata?.length > 0) { throw Error("Cannot set role of account holder") } } @@ -441,7 +443,6 @@ export const checkInvite = async (ctx: UserCtx) => { } catch (e) { console.warn("Error getting invite from code", e) ctx.throw(400, "There was a problem with the invite") - return } ctx.body = { email: invite.email, @@ -472,7 +473,6 @@ export const updateInvite = async ( invite = await cache.invite.getCode(code) } catch (e) { ctx.throw(400, "There was a problem with the invite") - return } let updated = {