Skip to content
Merged
1 change: 1 addition & 0 deletions api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ def xero_subscription(organisation): # type: ignore[no-untyped-def]
return subscription


@pytest.mark.saas_mode
@pytest.fixture()
def chargebee_subscription(organisation: Organisation) -> Subscription:
subscription = Subscription.objects.get(organisation=organisation)
Expand Down
2 changes: 1 addition & 1 deletion api/organisations/subscriptions/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ class UpgradeAPIUsagePaymentFailure(APIException):

class SubscriptionDoesNotSupportSeatUpgrade(APIException):
status_code = 400
default_detail = "Please Upgrade your plan to add additional seats/users"
default_detail = "Please upgrade your plan to add additional seats/users"
46 changes: 40 additions & 6 deletions api/tests/unit/organisations/invites/test_unit_invites_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def test_update_invite_link_returns_405(invite_link, admin_client, organisation)
def test_join_organisation_with_permission_groups(
organisation: Organisation,
user_permission_group: UserPermissionGroup,
subscription: Subscription,
enterprise_subscription: Subscription,
api_client: APIClient,
) -> None:
# Given
Expand All @@ -172,8 +172,9 @@ def test_join_organisation_with_permission_groups(
invite.permission_groups.add(user_permission_group)

# update subscription to add another seat
subscription.max_seats = 2
subscription.save()
current_seats = organisation.users.count()
enterprise_subscription.max_seats = current_seats + 1
enterprise_subscription.save()

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


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


@pytest.mark.saas_mode
@pytest.mark.parametrize(
"invite_object, url",
[
(lazy_fixture("invite"), "api-v1:users:user-join-organisation"),
(lazy_fixture("invite_link"), "api-v1:users:user-join-organisation-link"),
],
)
def test_join_organisation_returns_400_if_exceeds_plan_limit(
def test_join_organisation_returns_400_if_exceeds_plan_limit_for_saas(
staff_client: APIClient,
invite_object: Invite | InviteLink,
url: str,
Expand All @@ -352,7 +354,39 @@ def test_join_organisation_returns_400_if_exceeds_plan_limit(
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert (
response.json()["detail"]
== "Please Upgrade your plan to add additional seats/users"
== "Please upgrade your plan to add additional seats/users"
)


@pytest.mark.enterprise_mode
@pytest.mark.parametrize(
"invite_object, url",
[
(lazy_fixture("invite"), "api-v1:users:user-join-organisation"),
(lazy_fixture("invite_link"), "api-v1:users:user-join-organisation-link"),
],
)
def test_join_organisation_returns_400_if_exceeds_plan_limit_for_self_hosted_enterprise(
staff_client: APIClient,
invite_object: Invite | InviteLink,
url: str,
organisation: Organisation,
enterprise_subscription: Subscription,
) -> None:
# Given
url = reverse(url, args=[invite_object.hash])

enterprise_subscription.max_seats = 1
enterprise_subscription.save()

# When
response = staff_client.post(url)

# Then
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert (
response.json()["detail"]
== "Please upgrade your plan to add additional seats/users"
)


Expand Down
6 changes: 5 additions & 1 deletion api/users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import typing
import uuid

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

if settings.ENABLE_CHARGEBEE and organisation.over_plan_seats_limit(
# We purposefully allow self-hosted open source users to have unlimited users,
# but any paid or SaaS subscriptions must respect the seats limit.
# Ref: https://github.com/Flagsmith/flagsmith-private/issues/105
if (is_saas() or is_enterprise()) and organisation.over_plan_seats_limit(
additional_seats=1
):
if organisation.is_auto_seat_upgrade_available():
Expand Down
3 changes: 3 additions & 0 deletions frontend/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,9 @@ const Constants = {
: apiUrl
},
getUpgradeUrl: (feature?: string) => {
// TODO: deprecate usages of this helper function without
// providing feature since the billing page self hosted
// has links to the pricing page anyway.
return Utils.isSaas()
? '/organisation-settings?tab=billing'
: `https://www.flagsmith.com/pricing${
Expand Down
2 changes: 1 addition & 1 deletion frontend/common/stores/account-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const controller = {
error.status === 400
) {
API.ajaxHandler(store, error)
return
throw error
}
return data.post(`${Project.api}users/join/${id}/`)
})
Expand Down
19 changes: 14 additions & 5 deletions frontend/web/components/pages/InvitePage.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { Component } from 'react'
import Constants from 'common/constants'
import { withRouter } from 'react-router-dom'
import AccountProvider from 'common/providers/AccountProvider'
const InvitePage = class extends Component {
static displayName = 'InvitePage'

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

getErrorMessage(error) {
switch (error) {
case 'No Invite matches the given query.':
case 'Not found.':
return 'We could not validate your invite, please check the invite URL and email address you have entered is correct.'
case 'Please upgrade your plan to add additional seats/users':
return 'The organisation you have been invited to has no seats available. Please contact the organisation administrator to resolve this before trying again.'
default:
return error
}
}

render() {
return (
<div className='app-container'>
Expand All @@ -29,11 +42,7 @@ const InvitePage = class extends Component {
{error ? (
<div>
<h3 className='pt-5'>Oops</h3>
<p>
{error.detail === 'Not found.'
? 'We could not validate your invite, please check the invite URL and email address you have entered is correct.'
: error.detail}
</p>
<p>{this.getErrorMessage(error)}</p>
</div>
) : (
<Loader />
Expand Down
34 changes: 24 additions & 10 deletions frontend/web/components/pages/UsersAndPermissionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ const UsersAndPermissionsInner: FC<UsersAndPermissionsInnerType> = ({
const [resendUserInvite] = useResendUserInviteMutation()

const invites = userInvitesData?.results
const paymentsEnabled = Utils.getFlagsmithHasFeature('payments_enabled')
const verifySeatsLimit = Utils.getFlagsmithHasFeature(
'verify_seats_limit_for_invite_links',
)
Expand Down Expand Up @@ -121,9 +120,11 @@ const UsersAndPermissionsInner: FC<UsersAndPermissionsInnerType> = ({
const meta = subscriptionMeta || organisation.subscription || { max_seats: 1 }
const max_seats = meta.max_seats || 1
const isAWS = AccountStore.getPaymentMethod() === 'AWS_MARKETPLACE'
const autoSeats = !isAWS && Utils.getPlansPermission('AUTO_SEATS')
const usedSeats = paymentsEnabled && organisation.num_seats >= max_seats
const overSeats = paymentsEnabled && organisation.num_seats > max_seats
const autoSeats =
Utils.isSaas() && !isAWS && Utils.getPlansPermission('AUTO_SEATS')
const isSaasOrEnterprise = Utils.isSaas() || Utils.isEnterpriseImage()
const usedSeats = isSaasOrEnterprise && organisation.num_seats >= max_seats
const overSeats = isSaasOrEnterprise && organisation.num_seats > max_seats
const [role, setRole] = useState<'ADMIN' | 'USER'>('ADMIN')

const deleteInvite = (id: number) => {
Expand Down Expand Up @@ -218,7 +219,7 @@ const UsersAndPermissionsInner: FC<UsersAndPermissionsInnerType> = ({
)}
</Row>
<FormGroup className='mt-2'>
{paymentsEnabled && !isLoading && (
{!isLoading && isSaasOrEnterprise && (
<div className='col-md-6 mt-3 mb-4'>
<InfoMessage>
{'You are currently using '}
Expand All @@ -238,11 +239,24 @@ const UsersAndPermissionsInner: FC<UsersAndPermissionsInnerType> = ({
<strong>
If you wish to invite any additional
members, please{' '}
{
<a href='#' onClick={openChat}>
Contact us
{Utils.isSaas() ? (
<a
href='#'
onClick={(e) => {
e.stopPropagation()
openChat()
}}
>
contact us
</a>
}
) : (
<a
href='mailto:support@flagsmith.com'
onClick={(e) => e.stopPropagation()}
>
contact us
</a>
)}
.
</strong>
) : needsUpgradeForAdditionalSeats ? (
Expand All @@ -254,7 +268,7 @@ const UsersAndPermissionsInner: FC<UsersAndPermissionsInnerType> = ({
href='#'
onClick={() => {
history.replace(
Constants.getUpgradeUrl(),
'/organisation-settings?tab=billing',
)
}}
>
Expand Down
Loading