Merge pull request #10542 from Budibase/user-limit-ui

Adds account locking if user limit is exceeded
This commit is contained in:
José Vte. Calderón 2023-05-17 09:20:55 +02:00 committed by GitHub
commit 4928778b0b
10 changed files with 118 additions and 53 deletions

View File

@ -62,6 +62,7 @@
"@budibase/frontend-core": "0.0.1", "@budibase/frontend-core": "0.0.1",
"@budibase/shared-core": "0.0.1", "@budibase/shared-core": "0.0.1",
"@budibase/string-templates": "0.0.1", "@budibase/string-templates": "0.0.1",
"@budibase/types": "0.0.1",
"@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1", "@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1",

View File

@ -0,0 +1,31 @@
<script>
import { Modal, ModalContent, Body } from "@budibase/bbui"
let modal
export let onConfirm
export function show() {
modal.show()
}
export function hide() {
modal.hide()
}
</script>
<Modal bind:this={modal} on:hide={modal}>
<ModalContent
title="Your account is currently de-activated"
size="S"
showCancelButton={true}
showCloseIcon={false}
confirmText={"View plans"}
{onConfirm}
>
<Body size="S"
>Due to the free plan user limit being exceeded, your account has been
de-activated. Upgrade your plan to re-activate your account.</Body
>
</ModalContent>
</Modal>

View File

@ -3,7 +3,6 @@ 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
@ -146,23 +145,19 @@ const buildUsersAboveLimitBanner = EXPIRY_KEY => {
const userLicensing = get(licensing) const userLicensing = get(licensing)
return { return {
key: EXPIRY_KEY, key: EXPIRY_KEY,
type: BANNER_TYPES.WARNING, type: BANNER_TYPES.NEGATIVE,
onChange: () => { onChange: () => {
defaultCacheFn(EXPIRY_KEY) defaultCacheFn(EXPIRY_KEY)
}, },
criteria: () => { criteria: () => {
return userLicensing.warnUserLimit return userLicensing.errUserLimit
}, },
message: `${capitalise( message: "Your Budibase account is de-activated. Upgrade your plan",
userLicensing.license.plan.type
)} plan changes - Users will be limited to ${
userLicensing.userLimit
} users in ${userLicensing.userLimitDays}`,
...{ ...{
extraButtonText: "Find out more", extraButtonText: "View plans",
extraButtonAction: () => { extraButtonAction: () => {
defaultCacheFn(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER) defaultCacheFn(ExpiringKeys.LICENSING_USERS_ABOVE_LIMIT_BANNER)
window.location.href = "/builder/portal/users/users" window.location.href = "https://budibase.com/pricing/"
}, },
}, },
showCloseButton: true, showCloseButton: true,

View File

@ -6,6 +6,8 @@
export let app export let app
export let lockedAction
const handleDefaultClick = () => { const handleDefaultClick = () => {
if (window.innerWidth < 640) { if (window.innerWidth < 640) {
goToOverview() goToOverview()
@ -29,7 +31,7 @@
} }
</script> </script>
<div class="app-row" on:click={handleDefaultClick}> <div class="app-row" on:click={lockedAction || handleDefaultClick}>
<div class="title"> <div class="title">
<div class="app-icon"> <div class="app-icon">
<Icon size="L" name={app.icon?.name || "Apps"} color={app.icon?.color} /> <Icon size="L" name={app.icon?.name || "Apps"} color={app.icon?.color} />
@ -58,8 +60,11 @@
<div class="app-row-actions"> <div class="app-row-actions">
<AppLockModal {app} buttonSize="M" /> <AppLockModal {app} buttonSize="M" />
<Button size="S" secondary on:click={goToOverview}>Manage</Button> <Button size="S" secondary on:click={lockedAction || goToOverview}
<Button size="S" primary on:click={goToBuilder}>Edit</Button> >Manage</Button
>
<Button size="S" primary on:click={lockedAction || goToBuilder}>Edit</Button
>
</div> </div>
</div> </div>

View File

@ -133,7 +133,7 @@
</Body> </Body>
</Layout> </Layout>
<Divider /> <Divider />
{#if $licensing.usageMetrics?.dayPasses >= 100} {#if $licensing.usageMetrics?.dayPasses >= 100 || $licensing.errUserLimit}
<div> <div>
<Layout gap="S" justifyItems="center"> <Layout gap="S" justifyItems="center">
<img class="spaceman" alt="spaceman" src={Spaceman} /> <img class="spaceman" alt="spaceman" src={Spaceman} />

View File

@ -14,6 +14,7 @@
import Spinner from "components/common/Spinner.svelte" import Spinner from "components/common/Spinner.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte" import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import AccountLockedModal from "components/portal/licensing/AccountLockedModal.svelte"
import { store, automationStore } from "builderStore" import { store, automationStore } from "builderStore"
import { API } from "api" import { API } from "api"
@ -28,6 +29,7 @@
let template let template
let creationModal let creationModal
let appLimitModal let appLimitModal
let accountLockedModal
let creatingApp = false let creatingApp = false
let searchTerm = "" let searchTerm = ""
let creatingFromTemplate = false let creatingFromTemplate = false
@ -48,6 +50,11 @@
: true) : true)
) )
$: automationErrors = getAutomationErrors(enrichedApps) $: automationErrors = getAutomationErrors(enrichedApps)
$: isOwner = $auth.accountPortalAccess && $admin.cloud
const usersLimitLockAction = $licensing?.errUserLimit
? () => accountLockedModal.show()
: null
const enrichApps = (apps, user, sortBy) => { const enrichApps = (apps, user, sortBy) => {
const enrichedApps = apps.map(app => ({ const enrichedApps = apps.map(app => ({
@ -189,6 +196,9 @@
creatingFromTemplate = true creatingFromTemplate = true
createAppFromTemplateUrl(initInfo.init_template) createAppFromTemplateUrl(initInfo.init_template)
} }
if (usersLimitLockAction) {
usersLimitLockAction()
}
} catch (error) { } catch (error) {
notifications.error("Error getting init info") notifications.error("Error getting init info")
} }
@ -230,20 +240,30 @@
<Layout noPadding gap="L"> <Layout noPadding gap="L">
<div class="title"> <div class="title">
<div class="buttons"> <div class="buttons">
<Button size="M" cta on:click={initiateAppCreation}> <Button
size="M"
cta
on:click={usersLimitLockAction || initiateAppCreation}
>
Create new app Create new app
</Button> </Button>
{#if $apps?.length > 0} {#if $apps?.length > 0}
<Button <Button
size="M" size="M"
secondary secondary
on:click={$goto("/builder/portal/apps/templates")} on:click={usersLimitLockAction ||
$goto("/builder/portal/apps/templates")}
> >
View templates View templates
</Button> </Button>
{/if} {/if}
{#if !$apps?.length} {#if !$apps?.length}
<Button size="L" quiet secondary on:click={initiateAppImport}> <Button
size="L"
quiet
secondary
on:click={usersLimitLockAction || initiateAppImport}
>
Import app Import app
</Button> </Button>
{/if} {/if}
@ -267,7 +287,7 @@
<div class="app-table"> <div class="app-table">
{#each filteredApps as app (app.appId)} {#each filteredApps as app (app.appId)}
<AppRow {app} /> <AppRow {app} lockedAction={usersLimitLockAction} />
{/each} {/each}
</div> </div>
</Layout> </Layout>
@ -294,6 +314,11 @@
</Modal> </Modal>
<AppLimitModal bind:this={appLimitModal} /> <AppLimitModal bind:this={appLimitModal} />
<AccountLockedModal
bind:this={accountLockedModal}
onConfirm={() =>
isOwner ? $licensing.goToUpgradePage() : $licensing.goToPricingPage()}
/>
<style> <style>
.title { .title {

View File

@ -30,8 +30,8 @@
$: hasError = userData.find(x => x.error != null) $: hasError = userData.find(x => x.error != null)
$: userCount = $licensing.userCount + userData.length $: userCount = $licensing.userCount + userData.length
$: willReach = licensing.willReachUserLimit(userCount) $: reached = licensing.usersLimitReached(userCount)
$: willExceed = licensing.willExceedUserLimit(userCount) $: exceeded = licensing.usersLimitExceeded(userCount)
function removeInput(idx) { function removeInput(idx) {
userData = userData.filter((e, i) => i !== idx) userData = userData.filter((e, i) => i !== idx)
@ -87,7 +87,7 @@
confirmDisabled={disabled} confirmDisabled={disabled}
cancelText="Cancel" cancelText="Cancel"
showCloseIcon={false} showCloseIcon={false}
disabled={hasError || !userData.length || willExceed} disabled={hasError || !userData.length || exceeded}
> >
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
<Label>Email address</Label> <Label>Email address</Label>
@ -118,7 +118,7 @@
</div> </div>
{/each} {/each}
{#if willReach} {#if reached}
<div class="user-notification"> <div class="user-notification">
<Icon name="Info" /> <Icon name="Info" />
<span> <span>

View File

@ -25,10 +25,10 @@
$: invalidEmails = [] $: invalidEmails = []
$: userCount = $licensing.userCount + userEmails.length $: userCount = $licensing.userCount + userEmails.length
$: willExceed = licensing.willExceedUserLimit(userCount) $: exceed = licensing.usersLimitExceeded(userCount)
$: importDisabled = $: importDisabled =
!userEmails.length || !validEmails(userEmails) || !usersRole || willExceed !userEmails.length || !validEmails(userEmails) || !usersRole || exceed
const validEmails = userEmails => { const validEmails = userEmails => {
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) { if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) {
@ -93,7 +93,7 @@
</label> </label>
</div> </div>
{#if willExceed} {#if exceed}
<div class="user-notification"> <div class="user-notification">
<Icon name="Info" /> <Icon name="Info" />
{capitalise($licensing.license.plan.type)} plan is limited to {$licensing.userLimit} {capitalise($licensing.license.plan.type)} plan is limited to {$licensing.userLimit}

View File

@ -268,8 +268,6 @@
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">
@ -278,7 +276,7 @@
<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} {#if $licensing.errUserLimit}
<InlineAlert <InlineAlert
type="error" type="error"
onConfirm={() => { onConfirm={() => {
@ -290,13 +288,9 @@
}} }}
buttonText={isOwner ? "Upgrade" : "View plans"} buttonText={isOwner ? "Upgrade" : "View plans"}
cta cta
header={`Users will soon be limited to ${staticUserLimit}`} header="Account de-activated"
message={`Our free plan is going to be limited to ${staticUserLimit} users in ${$licensing.userLimitDays}. message="Due to the free plan user limit being exceeded, your account has been de-activated.
Upgrade your plan to re-activate your account."
This means any users exceeding the limit will be 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} {/if}
<div class="controls"> <div class="controls">

View File

@ -4,7 +4,7 @@ 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" import { PlanModel } from "@budibase/types"
const UNLIMITED = -1 const UNLIMITED = -1
@ -12,6 +12,7 @@ export const createLicensingStore = () => {
const DEFAULT = { const DEFAULT = {
// navigation // navigation
goToUpgradePage: () => {}, goToUpgradePage: () => {},
goToPricingPage: () => {},
// the top level license // the top level license
license: undefined, license: undefined,
isFreePlan: true, isFreePlan: true,
@ -37,29 +38,37 @@ export const createLicensingStore = () => {
// user limits // user limits
userCount: undefined, userCount: undefined,
userLimit: undefined, userLimit: undefined,
userLimitDays: undefined,
userLimitReached: false, userLimitReached: false,
warnUserLimit: false, errUserLimit: false,
} }
const oneDayInMilliseconds = 86400000 const oneDayInMilliseconds = 86400000
const store = writable(DEFAULT) const store = writable(DEFAULT)
function willReachUserLimit(userCount, userLimit) { function usersLimitReached(userCount, userLimit) {
if (userLimit === UNLIMITED) { if (userLimit === UNLIMITED) {
return false return false
} }
return userCount >= userLimit return userCount >= userLimit
} }
function willExceedUserLimit(userCount, userLimit) { function usersLimitExceeded(userCount, userLimit) {
if (userLimit === UNLIMITED) { if (userLimit === UNLIMITED) {
return false return false
} }
return userCount > userLimit return userCount > userLimit
} }
async function isCloud() {
let adminStore = get(admin)
if (!adminStore.loaded) {
await admin.init()
adminStore = get(admin)
}
return adminStore.cloud
}
const actions = { const actions = {
init: async () => { init: async () => {
actions.setNavigation() actions.setNavigation()
@ -71,10 +80,14 @@ export const createLicensingStore = () => {
const goToUpgradePage = () => { const goToUpgradePage = () => {
window.location.href = upgradeUrl window.location.href = upgradeUrl
} }
const goToPricingPage = () => {
window.open("https://budibase.com/pricing/", "_blank")
}
store.update(state => { store.update(state => {
return { return {
...state, ...state,
goToUpgradePage, goToUpgradePage,
goToPricingPage,
} }
}) })
}, },
@ -128,15 +141,15 @@ export const createLicensingStore = () => {
quotaUsage, quotaUsage,
} }
}) })
actions.setUsageMetrics() await actions.setUsageMetrics()
}, },
willReachUserLimit: userCount => { usersLimitReached: userCount => {
return willReachUserLimit(userCount, get(store).userLimit) return usersLimitReached(userCount, get(store).userLimit)
}, },
willExceedUserLimit(userCount) { usersLimitExceeded(userCount) {
return willExceedUserLimit(userCount, get(store).userLimit) return usersLimitExceeded(userCount, get(store).userLimit)
}, },
setUsageMetrics: () => { setUsageMetrics: async () => {
if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) { if (isEnabled(TENANT_FEATURE_FLAGS.LICENSING)) {
const usage = get(store).quotaUsage const usage = get(store).quotaUsage
const license = get(auth).user.license const license = get(auth).user.license
@ -198,11 +211,13 @@ export const createLicensingStore = () => {
const userQuota = license.quotas.usage.static.users const userQuota = license.quotas.usage.static.users
const userLimit = userQuota?.value const userLimit = userQuota?.value
const userCount = usage.usageQuota.users const userCount = usage.usageQuota.users
const userLimitReached = willReachUserLimit(userCount, userLimit) const userLimitReached = usersLimitReached(userCount, userLimit)
const userLimitExceeded = willExceedUserLimit(userCount, userLimit) const userLimitExceeded = usersLimitExceeded(userCount, userLimit)
const days = dayjs(userQuota?.startDate).diff(dayjs(), "day") const isCloudAccount = await isCloud()
const userLimitDays = days > 1 ? `${days} days` : "1 day" const errUserLimit =
const warnUserLimit = userQuota?.startDate && userLimitExceeded isCloudAccount &&
license.plan.model === PlanModel.PER_USER &&
userLimitExceeded
store.update(state => { store.update(state => {
return { return {
@ -217,9 +232,8 @@ export const createLicensingStore = () => {
// user limits // user limits
userCount, userCount,
userLimit, userLimit,
userLimitDays,
userLimitReached, userLimitReached,
warnUserLimit, errUserLimit,
} }
}) })
} }