Skip to content

Commit 17671d0

Browse files
fix: Enforce seat limit self hosted (#6663)
1 parent a67e9db commit 17671d0

8 files changed

Lines changed: 89 additions & 24 deletions

File tree

api/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ def xero_subscription(organisation): # type: ignore[no-untyped-def]
305305
return subscription
306306

307307

308+
@pytest.mark.saas_mode
308309
@pytest.fixture()
309310
def chargebee_subscription(organisation: Organisation) -> Subscription:
310311
subscription = Subscription.objects.get(organisation=organisation)

api/organisations/subscriptions/exceptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,4 @@ class UpgradeAPIUsagePaymentFailure(APIException):
3636

3737
class SubscriptionDoesNotSupportSeatUpgrade(APIException):
3838
status_code = 400
39-
default_detail = "Please Upgrade your plan to add additional seats/users"
39+
default_detail = "Please upgrade your plan to add additional seats/users"

api/tests/unit/organisations/invites/test_unit_invites_views.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ def test_update_invite_link_returns_405(invite_link, admin_client, organisation)
159159
def test_join_organisation_with_permission_groups(
160160
organisation: Organisation,
161161
user_permission_group: UserPermissionGroup,
162-
subscription: Subscription,
162+
enterprise_subscription: Subscription,
163163
api_client: APIClient,
164164
) -> None:
165165
# Given
@@ -172,8 +172,9 @@ def test_join_organisation_with_permission_groups(
172172
invite.permission_groups.add(user_permission_group)
173173

174174
# update subscription to add another seat
175-
subscription.max_seats = 2
176-
subscription.save()
175+
current_seats = organisation.users.count()
176+
enterprise_subscription.max_seats = current_seats + 1
177+
enterprise_subscription.save()
177178

178179
url = reverse("api-v1:users:user-join-organisation", args=[invite.hash])
179180
data = {"hubspotutk": "somehubspotdata"}
@@ -284,7 +285,7 @@ def test_create_invite_returns_400_if_seats_are_over(
284285
assert response.status_code == status.HTTP_400_BAD_REQUEST
285286
assert (
286287
response.json()["detail"]
287-
== "Please Upgrade your plan to add additional seats/users"
288+
== "Please upgrade your plan to add additional seats/users"
288289
)
289290

290291

@@ -329,14 +330,15 @@ def test_update_invite_returns_405( # type: ignore[no-untyped-def]
329330
assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
330331

331332

333+
@pytest.mark.saas_mode
332334
@pytest.mark.parametrize(
333335
"invite_object, url",
334336
[
335337
(lazy_fixture("invite"), "api-v1:users:user-join-organisation"),
336338
(lazy_fixture("invite_link"), "api-v1:users:user-join-organisation-link"),
337339
],
338340
)
339-
def test_join_organisation_returns_400_if_exceeds_plan_limit(
341+
def test_join_organisation_returns_400_if_exceeds_plan_limit_for_saas(
340342
staff_client: APIClient,
341343
invite_object: Invite | InviteLink,
342344
url: str,
@@ -352,7 +354,39 @@ def test_join_organisation_returns_400_if_exceeds_plan_limit(
352354
assert response.status_code == status.HTTP_400_BAD_REQUEST
353355
assert (
354356
response.json()["detail"]
355-
== "Please Upgrade your plan to add additional seats/users"
357+
== "Please upgrade your plan to add additional seats/users"
358+
)
359+
360+
361+
@pytest.mark.enterprise_mode
362+
@pytest.mark.parametrize(
363+
"invite_object, url",
364+
[
365+
(lazy_fixture("invite"), "api-v1:users:user-join-organisation"),
366+
(lazy_fixture("invite_link"), "api-v1:users:user-join-organisation-link"),
367+
],
368+
)
369+
def test_join_organisation_returns_400_if_exceeds_plan_limit_for_self_hosted_enterprise(
370+
staff_client: APIClient,
371+
invite_object: Invite | InviteLink,
372+
url: str,
373+
organisation: Organisation,
374+
enterprise_subscription: Subscription,
375+
) -> None:
376+
# Given
377+
url = reverse(url, args=[invite_object.hash])
378+
379+
enterprise_subscription.max_seats = 1
380+
enterprise_subscription.save()
381+
382+
# When
383+
response = staff_client.post(url)
384+
385+
# Then
386+
assert response.status_code == status.HTTP_400_BAD_REQUEST
387+
assert (
388+
response.json()["detail"]
389+
== "Please upgrade your plan to add additional seats/users"
356390
)
357391

358392

api/users/models.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import typing
55
import uuid
66

7+
from common.core.utils import is_enterprise, is_saas
78
from django.conf import settings
89
from django.contrib.auth.base_user import BaseUserManager
910
from django.contrib.auth.models import AbstractUser
@@ -237,7 +238,10 @@ def join_organisation_from_invite_link(self, invite_link: "InviteLink"): # type
237238
def join_organisation_from_invite(self, invite: "AbstractBaseInviteModel"): # type: ignore[no-untyped-def]
238239
organisation = invite.organisation
239240

240-
if settings.ENABLE_CHARGEBEE and organisation.over_plan_seats_limit(
241+
# We purposefully allow self-hosted open source users to have unlimited users,
242+
# but any paid or SaaS subscriptions must respect the seats limit.
243+
# Ref: https://github.com/Flagsmith/flagsmith-private/issues/105
244+
if (is_saas() or is_enterprise()) and organisation.over_plan_seats_limit(
241245
additional_seats=1
242246
):
243247
if organisation.is_auto_seat_upgrade_available():

frontend/common/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -470,6 +470,9 @@ const Constants = {
470470
: apiUrl
471471
},
472472
getUpgradeUrl: (feature?: string) => {
473+
// TODO: deprecate usages of this helper function without
474+
// providing feature since the billing page self hosted
475+
// has links to the pricing page anyway.
473476
return Utils.isSaas()
474477
? '/organisation-settings?tab=billing'
475478
: `https://www.flagsmith.com/pricing${

frontend/common/stores/account-store.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const controller = {
2525
error.status === 400
2626
) {
2727
API.ajaxHandler(store, error)
28-
return
28+
throw error
2929
}
3030
return data.post(`${Project.api}users/join/${id}/`)
3131
})

frontend/web/components/pages/InvitePage.js

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { Component } from 'react'
22
import Constants from 'common/constants'
33
import { withRouter } from 'react-router-dom'
4+
import AccountProvider from 'common/providers/AccountProvider'
45
const InvitePage = class extends Component {
56
static displayName = 'InvitePage'
67

@@ -19,6 +20,18 @@ const InvitePage = class extends Component {
1920
this.props.history.replace(Utils.getOrganisationHomePage(id))
2021
}
2122

23+
getErrorMessage(error) {
24+
switch (error) {
25+
case 'No Invite matches the given query.':
26+
case 'Not found.':
27+
return 'We could not validate your invite, please check the invite URL and email address you have entered is correct.'
28+
case 'Please upgrade your plan to add additional seats/users':
29+
return 'The organisation you have been invited to has no seats available. Please contact the organisation administrator to resolve this before trying again.'
30+
default:
31+
return error
32+
}
33+
}
34+
2235
render() {
2336
return (
2437
<div className='app-container'>
@@ -29,11 +42,7 @@ const InvitePage = class extends Component {
2942
{error ? (
3043
<div>
3144
<h3 className='pt-5'>Oops</h3>
32-
<p>
33-
{error.detail === 'Not found.'
34-
? 'We could not validate your invite, please check the invite URL and email address you have entered is correct.'
35-
: error.detail}
36-
</p>
45+
<p>{this.getErrorMessage(error)}</p>
3746
</div>
3847
) : (
3948
<Loader />

frontend/web/components/pages/UsersAndPermissionsPage.tsx

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ const UsersAndPermissionsInner: FC<UsersAndPermissionsInnerType> = ({
8080
const [resendUserInvite] = useResendUserInviteMutation()
8181

8282
const invites = userInvitesData?.results
83-
const paymentsEnabled = Utils.getFlagsmithHasFeature('payments_enabled')
8483
const verifySeatsLimit = Utils.getFlagsmithHasFeature(
8584
'verify_seats_limit_for_invite_links',
8685
)
@@ -121,9 +120,11 @@ const UsersAndPermissionsInner: FC<UsersAndPermissionsInnerType> = ({
121120
const meta = subscriptionMeta || organisation.subscription || { max_seats: 1 }
122121
const max_seats = meta.max_seats || 1
123122
const isAWS = AccountStore.getPaymentMethod() === 'AWS_MARKETPLACE'
124-
const autoSeats = !isAWS && Utils.getPlansPermission('AUTO_SEATS')
125-
const usedSeats = paymentsEnabled && organisation.num_seats >= max_seats
126-
const overSeats = paymentsEnabled && organisation.num_seats > max_seats
123+
const autoSeats =
124+
Utils.isSaas() && !isAWS && Utils.getPlansPermission('AUTO_SEATS')
125+
const isSaasOrEnterprise = Utils.isSaas() || Utils.isEnterpriseImage()
126+
const usedSeats = isSaasOrEnterprise && organisation.num_seats >= max_seats
127+
const overSeats = isSaasOrEnterprise && organisation.num_seats > max_seats
127128
const [role, setRole] = useState<'ADMIN' | 'USER'>('ADMIN')
128129

129130
const deleteInvite = (id: number) => {
@@ -218,7 +219,7 @@ const UsersAndPermissionsInner: FC<UsersAndPermissionsInnerType> = ({
218219
)}
219220
</Row>
220221
<FormGroup className='mt-2'>
221-
{paymentsEnabled && !isLoading && (
222+
{!isLoading && isSaasOrEnterprise && (
222223
<div className='col-md-6 mt-3 mb-4'>
223224
<InfoMessage>
224225
{'You are currently using '}
@@ -238,11 +239,24 @@ const UsersAndPermissionsInner: FC<UsersAndPermissionsInnerType> = ({
238239
<strong>
239240
If you wish to invite any additional
240241
members, please{' '}
241-
{
242-
<a href='#' onClick={openChat}>
243-
Contact us
242+
{Utils.isSaas() ? (
243+
<a
244+
href='#'
245+
onClick={(e) => {
246+
e.stopPropagation()
247+
openChat()
248+
}}
249+
>
250+
contact us
244251
</a>
245-
}
252+
) : (
253+
<a
254+
href='mailto:support@flagsmith.com'
255+
onClick={(e) => e.stopPropagation()}
256+
>
257+
contact us
258+
</a>
259+
)}
246260
.
247261
</strong>
248262
) : needsUpgradeForAdditionalSeats ? (
@@ -254,7 +268,7 @@ const UsersAndPermissionsInner: FC<UsersAndPermissionsInnerType> = ({
254268
href='#'
255269
onClick={() => {
256270
history.replace(
257-
Constants.getUpgradeUrl(),
271+
'/organisation-settings?tab=billing',
258272
)
259273
}}
260274
>

0 commit comments

Comments
 (0)