Browse Source

feat(billing): Invite billing members to a developer plan (#80129)

Jarrett Scott 3 months ago
parent
commit
258300bbe9

+ 11 - 2
src/sentry/api/endpoints/organization_member/index.py

@@ -137,6 +137,10 @@ class OrganizationMemberRequestSerializer(serializers.Serializer):
         return self.validate_orgRole(role)
 
     def validate_orgRole(self, role):
+        if role == "billing" and features.has(
+            "organizations:invite-billing", self.context["organization"]
+        ):
+            return role
         role_obj = next((r for r in self.context["allowed_roles"] if r.id == role), None)
         if role_obj is None:
             raise serializers.ValidationError(
@@ -314,13 +318,18 @@ class OrganizationMemberIndexEndpoint(OrganizationEndpoint):
         """
         Add or invite a member to an organization.
         """
-        if not features.has("organizations:invite-members", organization, actor=request.user):
+        assigned_org_role = request.data.get("orgRole") or request.data.get("role")
+        billing_bypass = assigned_org_role == "billing" and features.has(
+            "organizations:invite-billing", organization
+        )
+        if not billing_bypass and not features.has(
+            "organizations:invite-members", organization, actor=request.user
+        ):
             return Response(
                 {"organization": "Your organization is not allowed to invite members"}, status=403
             )
 
         allowed_roles = get_allowed_org_roles(request, organization, creating_org_invite=True)
-        assigned_org_role = request.data.get("orgRole") or request.data.get("role")
 
         # We allow requests from integration tokens to invite new members as the member role only
         if not allowed_roles and request.access.is_integration_token:

+ 2 - 0
src/sentry/features/temporary.py

@@ -154,6 +154,8 @@ def register_temporary_features(manager: FeatureManager):
     manager.add("organizations:integrations-feature-flag-integration", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False)
     # Allow tenant type installations through issue alert actions
     manager.add("organizations:integrations-msteams-tenant", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False)
+    # Enable inviting billing members to organizations at the member limit.
+    manager.add("organizations:invite-billing", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=False, api_expose=False)
     # Enable inviting members to organizations.
     manager.add("organizations:invite-members", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=True, api_expose=True)
     # Enable new invite members modal.

+ 9 - 1
static/app/components/modals/inviteMembersModal/index.tsx

@@ -82,7 +82,12 @@ function InviteMembersModal({
         willInvite={willInvite}
         onSendInvites={sendInvites}
       >
-        {({sendInvites: inviteModalSendInvites, canSend, headerInfo}) => {
+        {({
+          sendInvites: inviteModalSendInvites,
+          canSend: canSend,
+          headerInfo: headerInfo,
+          isOverMemberLimit: isOverMemberLimit,
+        }) => {
           return organization.features.includes('invite-members-new-modal') ? (
             <InviteMembersContext.Provider
               value={{
@@ -133,6 +138,9 @@ function InviteMembersModal({
               headerInfo={headerInfo}
               invites={invites}
               inviteStatus={inviteStatus}
+              isOverMemberLimit={
+                isOverMemberLimit && organization.features?.includes('invite-billing')
+              }
               member={memberResult.data}
               pendingInvites={pendingInvites}
               removeInviteRow={removeInviteRow}

+ 29 - 0
static/app/components/modals/inviteMembersModal/inviteMembersModalview.spec.tsx

@@ -26,6 +26,29 @@ describe('InviteMembersModalView', function () {
     setRole: () => {},
     setTeams: () => {},
     willInvite: false,
+    isOverMemberLimit: false,
+  };
+
+  const overMemberLimitModalProps: ComponentProps<typeof InviteMembersModalView> = {
+    Footer: styledWrapper(),
+    addInviteRow: () => {},
+    canSend: true,
+    closeModal: () => {},
+    complete: false,
+    headerInfo: null,
+    inviteStatus: {},
+    invites: [],
+    member: undefined,
+    pendingInvites: [],
+    removeInviteRow: () => {},
+    reset: () => {},
+    sendInvites: () => {},
+    sendingInvites: false,
+    setEmails: () => {},
+    setRole: () => {},
+    setTeams: () => {},
+    willInvite: true,
+    isOverMemberLimit: true,
   };
 
   it('renders', function () {
@@ -45,4 +68,10 @@ describe('InviteMembersModalView', function () {
     // Check that the Alert component renders with the provided error message
     expect(screen.getByText('This is an error message')).toBeInTheDocument();
   });
+
+  it('renders when over member limit', function () {
+    render(<InviteMembersModalView {...overMemberLimitModalProps} />);
+
+    expect(screen.getByText('Invite New Members')).toBeInTheDocument();
+  });
 });

+ 15 - 6
static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx

@@ -1,5 +1,4 @@
-import type {ReactNode} from 'react';
-import {Fragment} from 'react';
+import {Fragment, type ReactNode, useEffect, useRef} from 'react';
 import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 
@@ -30,6 +29,7 @@ interface Props {
   headerInfo: ReactNode;
   inviteStatus: InviteStatus;
   invites: NormalizedInvite[];
+  isOverMemberLimit: boolean;
   member: Member | undefined;
   pendingInvites: InviteRow[];
   removeInviteRow: (index: number) => void;
@@ -52,6 +52,7 @@ export default function InviteMembersModalView({
   headerInfo,
   invites,
   inviteStatus,
+  isOverMemberLimit,
   member,
   pendingInvites,
   removeInviteRow,
@@ -76,6 +77,16 @@ export default function InviteMembersModalView({
     </Alert>
   ) : null;
 
+  const canSendRef = useRef(canSend);
+
+  useEffect(() => {
+    if (isOverMemberLimit) {
+      setRole('billing', 0);
+      setTeams([], 0);
+      canSendRef.current = true;
+    }
+  });
+
   return (
     <Fragment>
       {errorAlert}
@@ -115,10 +126,10 @@ export default function InviteMembersModalView({
             onChangeRole={value => setRole(value?.value, i)}
             onChangeTeams={opts => setTeams(opts ? opts.map(v => v.value) : [], i)}
             disableRemove={disableInputs || pendingInvites.length === 1}
+            isOverMemberLimit={isOverMemberLimit}
           />
         ))}
       </Rows>
-
       <AddButton
         disabled={disableInputs}
         size="sm"
@@ -128,7 +139,6 @@ export default function InviteMembersModalView({
       >
         {t('Add another')}
       </AddButton>
-
       <Footer>
         <FooterContent>
           <div>
@@ -140,7 +150,6 @@ export default function InviteMembersModalView({
               willInvite={willInvite}
             />
           </div>
-
           <ButtonBar gap={1}>
             {complete ? (
               <Fragment>
@@ -172,7 +181,7 @@ export default function InviteMembersModalView({
                   size="sm"
                   data-test-id="send-invites"
                   priority="primary"
-                  disabled={!canSend || !isValidInvites || disableInputs}
+                  disabled={!canSendRef.current || !isValidInvites || disableInputs}
                   onClick={sendInvites}
                 />
               </Fragment>

+ 3 - 1
static/app/components/modals/inviteMembersModal/inviteRowControl.tsx

@@ -23,6 +23,7 @@ type Props = {
   disabled: boolean;
   emails: string[];
   inviteStatus: InviteStatus;
+  isOverMemberLimit: boolean;
   onChangeEmails: (emails: SelectOption[]) => void;
   onChangeRole: (role: SelectOption) => void;
   onChangeTeams: (teams: SelectOption[]) => void;
@@ -52,6 +53,7 @@ function InviteRowControl({
   onChangeRole,
   onChangeTeams,
   disableRemove,
+  isOverMemberLimit,
 }: Props) {
   const [inputValue, setInputValue] = useState('');
 
@@ -118,7 +120,7 @@ function InviteRowControl({
       <RoleSelectControl
         aria-label={t('Role')}
         data-test-id="select-role"
-        disabled={disabled}
+        disabled={isOverMemberLimit ? true : disabled}
         value={role}
         roles={roleOptions}
         disableUnallowed={roleDisabledUnallowed}

+ 1 - 1
static/app/components/modals/memberInviteModalCustomization.tsx

@@ -3,7 +3,7 @@ import HookOrDefault from 'sentry/components/hookOrDefault';
 export const InviteModalHook = HookOrDefault({
   hookName: 'member-invite-modal:customization',
   defaultComponent: ({onSendInvites, children}) =>
-    children({sendInvites: onSendInvites, canSend: true}),
+    children({sendInvites: onSendInvites, canSend: true, isOverMemberLimit: false}),
 });
 
 export type InviteModalRenderFunc = React.ComponentProps<

+ 5 - 0
static/app/types/hooks.tsx

@@ -687,6 +687,11 @@ type InviteModalCustomizationHook = () => React.ComponentType<{
      * invites may currently be sent.
      */
     canSend: boolean;
+    /**
+     * Indicates that the account has reached the maximum member limit. Future invitations
+     * are limited to Billing roles
+     */
+    isOverMemberLimit: boolean;
     /**
      * Trigger sending invites
      */

+ 3 - 3
static/app/views/settings/organizationMembers/organizationMembersList.tsx

@@ -311,7 +311,7 @@ function OrganizationMembersList() {
           refetchInviteRequests();
           refetchMembers();
         }}
-        allowedRoles={currentMember ? currentMember.roles : ORG_ROLES}
+        allowedRoles={currentMember?.orgRoleList ?? currentMember?.roles ?? ORG_ROLES}
       />
       {inviteRequests.length > 0 && (
         <Panel>
@@ -329,7 +329,7 @@ function OrganizationMembersList() {
                 organization={organization}
                 inviteRequest={inviteRequest}
                 inviteRequestBusy={{}}
-                allRoles={currentMember?.roles ?? ORG_ROLES}
+                allRoles={currentMember?.orgRoleList ?? currentMember?.roles ?? ORG_ROLES}
                 onApprove={handleInviteRequestApprove}
                 onDeny={handleInviteRequestDeny}
                 onUpdate={data => updateInviteRequest(inviteRequest.id, data)}
@@ -340,7 +340,7 @@ function OrganizationMembersList() {
       )}
       <SearchWrapperWithFilter>
         <MembersFilter
-          roles={currentMember?.roles ?? ORG_ROLES}
+          roles={currentMember?.orgRoleList ?? currentMember?.roles ?? ORG_ROLES}
           query={searchQuery}
           onChange={handleQueryChange}
         />

+ 23 - 0
tests/sentry/api/endpoints/test_organization_member_index.py

@@ -154,6 +154,29 @@ class OrganizationMemberRequestSerializerTest(TestCase):
         assert not serializer.is_valid()
         assert serializer.errors == {"teamRoles": ["Invalid team-role"]}
 
+    @with_feature("organizations:invite-billing")
+    def test_valid_invite_billing_member(self):
+        context = {"organization": self.organization, "allowed_roles": [roles.get("member")]}
+        data = {
+            "email": "bill@localhost",
+            "orgRole": "billing",
+            "teamRoles": [],
+        }
+
+        serializer = OrganizationMemberRequestSerializer(context=context, data=data)
+        assert serializer.is_valid()
+
+    def test_invalid_invite_billing_member(self):
+        context = {"organization": self.organization, "allowed_roles": [roles.get("member")]}
+        data = {
+            "email": "bill@localhost",
+            "orgRole": "billing",
+            "teamRoles": [],
+        }
+
+        serializer = OrganizationMemberRequestSerializer(context=context, data=data)
+        assert not serializer.is_valid()
+
 
 class OrganizationMemberListTestBase(APITestCase):
     endpoint = "sentry-api-0-organization-member-index"