From 76f39679336482c652b7a99cd30bf2c6f3d9becc Mon Sep 17 00:00:00 2001 From: Jarrett Scott Date: Fri, 25 Oct 2024 09:35:05 -0700 Subject: [PATCH 01/17] WIP invite only billing members --- .../inviteMembersModal/inviteMembersModalview.tsx | 13 ++++++++++--- .../modals/inviteMembersModal/inviteRowControl.tsx | 8 +++++--- static/app/constants/index.tsx | 12 ++++++++++++ .../organizationMembers/organizationMembersList.tsx | 6 +++--- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx b/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx index 9e9bedfbd9ec8..652e5366a9fe1 100644 --- a/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx +++ b/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx @@ -15,11 +15,11 @@ import type { InviteStatus, NormalizedInvite, } from 'sentry/components/modals/inviteMembersModal/types'; -import {ORG_ROLES} from 'sentry/constants'; +import {BILLING_ROLE, ORG_ROLES} from 'sentry/constants'; import {IconAdd} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; -import type {Member} from 'sentry/types/organization'; +import type {Member, OrgRole} from 'sentry/types/organization'; interface Props { Footer: ModalRenderProps['Footer']; @@ -70,6 +70,12 @@ export default function InviteMembersModalView({ const hasDuplicateEmails = inviteEmails.length !== new Set(inviteEmails).size; const isValidInvites = invites.length > 0 && !hasDuplicateEmails; + // TODO Find a way to calculate this value without the Subscription object + const isOverLimit: boolean = true; + const roleOptions: OrgRole[] = isOverLimit + ? BILLING_ROLE + : member?.orgRoleList ?? ORG_ROLES; + const errorAlert = error ? ( {error} @@ -107,7 +113,7 @@ export default function InviteMembersModalView({ emails={[...emails]} role={role} teams={[...teams]} - roleOptions={member?.orgRoleList ?? ORG_ROLES} + roleOptions={roleOptions} roleDisabledUnallowed={willInvite} inviteStatus={inviteStatus} onRemove={() => removeInviteRow(i)} @@ -115,6 +121,7 @@ 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={isOverLimit} /> ))} diff --git a/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx b/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx index 7bbd7732576cc..cb9f6d718d48a 100644 --- a/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx +++ b/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx @@ -32,6 +32,7 @@ type Props = { roleOptions: OrgRole[]; teams: string[]; className?: string; + isOverMemberLimit?: boolean; }; function ValueComponent( @@ -59,6 +60,7 @@ function InviteRowControl({ onChangeRole, onChangeTeams, disableRemove, + isOverMemberLimit, }: Props) { const [inputValue, setInputValue] = useState(''); @@ -74,7 +76,7 @@ function InviteRowControl({ }, [roleOptions] ); - const isTeamRolesAllowed = isTeamRolesAllowedForRole(role); + const isTeamRolesAllowed = isOverMemberLimit ? false : isTeamRolesAllowedForRole(role); const handleKeyDown = (event: React.KeyboardEvent) => { switch (event.key) { @@ -123,8 +125,8 @@ function InviteRowControl({ { diff --git a/static/app/constants/index.tsx b/static/app/constants/index.tsx index 74b39e0ba69ee..a66bb83c795f7 100644 --- a/static/app/constants/index.tsx +++ b/static/app/constants/index.tsx @@ -76,6 +76,18 @@ export const ALLOWED_SCOPES = [ 'team:write', ] as const; +// TODO Find a way to create Billing members without defining this role in the sentry repo +export const BILLING_ROLE: OrgRole[] = [ + { + id: 'billing', + name: 'Billing', + isAllowed: true, + desc: 'Can manage subscription and billing details.', + minimumTeamRole: 'contributor', + isTeamRolesAllowed: true, + }, +]; + // These should only be used in the case where we cannot obtain roles through // the members endpoint (primarily in cases where a user is admining a // different organization they are not a OrganizationMember of ). diff --git a/static/app/views/settings/organizationMembers/organizationMembersList.tsx b/static/app/views/settings/organizationMembers/organizationMembersList.tsx index e9db726b4f093..7ae89d683705b 100644 --- a/static/app/views/settings/organizationMembers/organizationMembersList.tsx +++ b/static/app/views/settings/organizationMembers/organizationMembersList.tsx @@ -312,7 +312,7 @@ function OrganizationMembersList() { refetchInviteRequests(); refetchMembers(); }} - allowedRoles={currentMember ? currentMember.roles : ORG_ROLES} + allowedRoles={currentMember?.orgRoleList ?? currentMember?.roles ?? ORG_ROLES} /> {inviteRequests.length > 0 && ( @@ -330,7 +330,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)} @@ -341,7 +341,7 @@ function OrganizationMembersList() { )} From 648f007c5e3fbf082ba80075e5bfa37f2d7e6b90 Mon Sep 17 00:00:00 2001 From: Jarrett Scott Date: Tue, 29 Oct 2024 10:48:01 -0700 Subject: [PATCH 02/17] WIP using react component hooks --- .../components/modals/inviteMembersModal/index.tsx | 8 +++++++- .../inviteMembersModalview.spec.tsx | 1 + .../inviteMembersModal/inviteMembersModalview.tsx | 12 +++++------- .../modals/inviteMembersModal/inviteRowControl.tsx | 2 +- .../modals/memberInviteModalCustomization.tsx | 2 +- static/app/constants/index.tsx | 12 ------------ static/app/types/hooks.tsx | 5 +++++ 7 files changed, 20 insertions(+), 22 deletions(-) diff --git a/static/app/components/modals/inviteMembersModal/index.tsx b/static/app/components/modals/inviteMembersModal/index.tsx index 88c3128b848b4..94853dbff0fa3 100644 --- a/static/app/components/modals/inviteMembersModal/index.tsx +++ b/static/app/components/modals/inviteMembersModal/index.tsx @@ -82,7 +82,12 @@ function InviteMembersModal({ willInvite={willInvite} onSendInvites={sendInvites} > - {({sendInvites: _sendInvites, canSend, headerInfo}) => { + {({ + sendInvites: _sendInvites, + canSend: canSend, + headerInfo: headerInfo, + isOverMemberLimit: isOverMemberLimit, + }) => { return organization.features.includes('invite-members-new-modal') ? ( {}, setTeams: () => {}, willInvite: false, + isOverMemberLimit: false, }; it('renders', function () { diff --git a/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx b/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx index 652e5366a9fe1..8a20d37915cc4 100644 --- a/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx +++ b/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx @@ -15,7 +15,7 @@ import type { InviteStatus, NormalizedInvite, } from 'sentry/components/modals/inviteMembersModal/types'; -import {BILLING_ROLE, ORG_ROLES} from 'sentry/constants'; +import {ORG_ROLES} from 'sentry/constants'; import {IconAdd} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; @@ -30,6 +30,7 @@ interface Props { headerInfo: ReactNode; inviteStatus: InviteStatus; invites: NormalizedInvite[]; + isOverMemberLimit: boolean; member: Member | undefined; pendingInvites: InviteRow[]; removeInviteRow: (index: number) => void; @@ -52,6 +53,7 @@ export default function InviteMembersModalView({ headerInfo, invites, inviteStatus, + isOverMemberLimit, member, pendingInvites, removeInviteRow, @@ -70,11 +72,7 @@ export default function InviteMembersModalView({ const hasDuplicateEmails = inviteEmails.length !== new Set(inviteEmails).size; const isValidInvites = invites.length > 0 && !hasDuplicateEmails; - // TODO Find a way to calculate this value without the Subscription object - const isOverLimit: boolean = true; - const roleOptions: OrgRole[] = isOverLimit - ? BILLING_ROLE - : member?.orgRoleList ?? ORG_ROLES; + const roleOptions: OrgRole[] = member?.orgRoleList ?? ORG_ROLES; const errorAlert = error ? ( @@ -121,7 +119,7 @@ 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={isOverLimit} + isOverMemberLimit={isOverMemberLimit} /> ))} diff --git a/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx b/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx index cb9f6d718d48a..6cd821db649f2 100644 --- a/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx +++ b/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; @@ -32,7 +33,6 @@ type Props = { roleOptions: OrgRole[]; teams: string[]; className?: string; - isOverMemberLimit?: boolean; }; function ValueComponent( diff --git a/static/app/components/modals/memberInviteModalCustomization.tsx b/static/app/components/modals/memberInviteModalCustomization.tsx index 26e5be1f25293..cbbaa49b0e4c3 100644 --- a/static/app/components/modals/memberInviteModalCustomization.tsx +++ b/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< diff --git a/static/app/constants/index.tsx b/static/app/constants/index.tsx index a66bb83c795f7..74b39e0ba69ee 100644 --- a/static/app/constants/index.tsx +++ b/static/app/constants/index.tsx @@ -76,18 +76,6 @@ export const ALLOWED_SCOPES = [ 'team:write', ] as const; -// TODO Find a way to create Billing members without defining this role in the sentry repo -export const BILLING_ROLE: OrgRole[] = [ - { - id: 'billing', - name: 'Billing', - isAllowed: true, - desc: 'Can manage subscription and billing details.', - minimumTeamRole: 'contributor', - isTeamRolesAllowed: true, - }, -]; - // These should only be used in the case where we cannot obtain roles through // the members endpoint (primarily in cases where a user is admining a // different organization they are not a OrganizationMember of ). diff --git a/static/app/types/hooks.tsx b/static/app/types/hooks.tsx index e2f77487e3edd..aef7bc3ddc6bd 100644 --- a/static/app/types/hooks.tsx +++ b/static/app/types/hooks.tsx @@ -679,6 +679,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 */ From 664d85537ce359db138e57c978807fe13308c6f6 Mon Sep 17 00:00:00 2001 From: Jarrett Scott Date: Tue, 29 Oct 2024 16:14:30 -0700 Subject: [PATCH 03/17] Unit test with isOverMemberLimit --- .../inviteMembersModalview.spec.tsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/static/app/components/modals/inviteMembersModal/inviteMembersModalview.spec.tsx b/static/app/components/modals/inviteMembersModal/inviteMembersModalview.spec.tsx index 648f2c12e65e7..d4cc90f5ca4bf 100644 --- a/static/app/components/modals/inviteMembersModal/inviteMembersModalview.spec.tsx +++ b/static/app/components/modals/inviteMembersModal/inviteMembersModalview.spec.tsx @@ -29,6 +29,28 @@ describe('InviteMembersModalView', function () { isOverMemberLimit: false, }; + const overMemberLimitModalProps: ComponentProps = { + 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 () { render(); @@ -46,4 +68,11 @@ 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(); + + expect(screen.getByText('Invite New Members')).toBeInTheDocument(); + expect(screen.getByText('Add another')).toBeInTheDocument(); + }); }); From 70fb92dccae006a76ee573747ad662a04e08e614 Mon Sep 17 00:00:00 2001 From: Jarrett Scott Date: Tue, 5 Nov 2024 11:19:54 -0800 Subject: [PATCH 04/17] Allow member invitations --- src/sentry/api/endpoints/organization_member/index.py | 6 +----- .../modals/inviteMembersModal/inviteMembersModalview.tsx | 6 +++++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/sentry/api/endpoints/organization_member/index.py b/src/sentry/api/endpoints/organization_member/index.py index d92ac5988194c..f3d39e6e25540 100644 --- a/src/sentry/api/endpoints/organization_member/index.py +++ b/src/sentry/api/endpoints/organization_member/index.py @@ -314,13 +314,9 @@ def post(self, request: Request, organization) -> Response: """ Add or invite a member to an organization. """ - if not features.has("organizations:invite-members", organization, actor=request.user): - return Response( - {"organization": "Your organization is not allowed to invite members"}, status=403 - ) + assigned_org_role = request.data.get("orgRole") or request.data.get("role") 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: diff --git a/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx b/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx index 8a20d37915cc4..18db5f45cfe2c 100644 --- a/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx +++ b/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx @@ -177,7 +177,11 @@ export default function InviteMembersModalView({ size="sm" data-test-id="send-invites" priority="primary" - disabled={!canSend || !isValidInvites || disableInputs} + disabled={ + isOverMemberLimit + ? false + : !canSend || !isValidInvites || disableInputs + } onClick={sendInvites} /> From 33c31afab44bfd3f3a18759767329668d5eac739 Mon Sep 17 00:00:00 2001 From: Jarrett Scott Date: Wed, 6 Nov 2024 15:27:06 -0800 Subject: [PATCH 05/17] Better RoleSelectControl --- .../components/modals/inviteMembersModal/inviteRowControl.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx b/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx index 6cd821db649f2..c8a24e5e4cd5c 100644 --- a/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx +++ b/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx @@ -125,7 +125,7 @@ function InviteRowControl({ Date: Wed, 6 Nov 2024 16:11:00 -0800 Subject: [PATCH 06/17] Formatting --- src/sentry/api/endpoints/organization_member/index.py | 2 +- .../modals/inviteMembersModal/inviteMembersModalview.tsx | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/sentry/api/endpoints/organization_member/index.py b/src/sentry/api/endpoints/organization_member/index.py index f3d39e6e25540..201ec023c28ec 100644 --- a/src/sentry/api/endpoints/organization_member/index.py +++ b/src/sentry/api/endpoints/organization_member/index.py @@ -314,9 +314,9 @@ def post(self, request: Request, organization) -> Response: """ Add or invite a member to an organization. """ - assigned_org_role = request.data.get("orgRole") or request.data.get("role") 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: diff --git a/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx b/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx index 18db5f45cfe2c..0fc416292a1ae 100644 --- a/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx +++ b/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx @@ -19,7 +19,7 @@ import {ORG_ROLES} from 'sentry/constants'; import {IconAdd} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; -import type {Member, OrgRole} from 'sentry/types/organization'; +import type {Member} from 'sentry/types/organization'; interface Props { Footer: ModalRenderProps['Footer']; @@ -72,8 +72,6 @@ export default function InviteMembersModalView({ const hasDuplicateEmails = inviteEmails.length !== new Set(inviteEmails).size; const isValidInvites = invites.length > 0 && !hasDuplicateEmails; - const roleOptions: OrgRole[] = member?.orgRoleList ?? ORG_ROLES; - const errorAlert = error ? ( {error} @@ -111,7 +109,7 @@ export default function InviteMembersModalView({ emails={[...emails]} role={role} teams={[...teams]} - roleOptions={roleOptions} + roleOptions={member?.orgRoleList ?? ORG_ROLES} roleDisabledUnallowed={willInvite} inviteStatus={inviteStatus} onRemove={() => removeInviteRow(i)} From 6f16a5feef39372e1ea3b7dfed4b132e4ab76a4b Mon Sep 17 00:00:00 2001 From: Jarrett Scott Date: Wed, 6 Nov 2024 17:00:04 -0800 Subject: [PATCH 07/17] Fix test --- .../api/endpoints/test_organization_member_index.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/sentry/api/endpoints/test_organization_member_index.py b/tests/sentry/api/endpoints/test_organization_member_index.py index 3ece837da375c..f8cb35829e804 100644 --- a/tests/sentry/api/endpoints/test_organization_member_index.py +++ b/tests/sentry/api/endpoints/test_organization_member_index.py @@ -550,12 +550,16 @@ def test_admin_invites(self): def test_member_invites(self): self.invite_all_helper("member") - def test_respects_feature_flag(self): + def test_ignores_feature_flag(self): user = self.create_user("baz@example.com") with Feature({"organizations:invite-members": False}): data = {"email": user.email, "role": "member", "teams": [self.team.slug]} - self.get_error_response(self.organization.slug, **data, status_code=403) + self.get_success_response(self.organization.slug, **data, status_code=201) + + with Feature({"organizations:invite-members": False}): + data = {"email": user.email, "role": "billing", "teams": [self.team.slug]} + self.get_success_response(self.organization.slug, **data, status_code=201) def test_no_team_invites(self): data = {"email": "eric@localhost", "role": "owner", "teams": []} From 46455032d24590b7450ad94850ad1be362483a34 Mon Sep 17 00:00:00 2001 From: Jarrett Scott Date: Wed, 6 Nov 2024 22:00:44 -0800 Subject: [PATCH 08/17] Fix test_ignores_feature_flag --- tests/sentry/api/endpoints/test_organization_member_index.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/sentry/api/endpoints/test_organization_member_index.py b/tests/sentry/api/endpoints/test_organization_member_index.py index f8cb35829e804..ed95dd8041a90 100644 --- a/tests/sentry/api/endpoints/test_organization_member_index.py +++ b/tests/sentry/api/endpoints/test_organization_member_index.py @@ -557,10 +557,6 @@ def test_ignores_feature_flag(self): data = {"email": user.email, "role": "member", "teams": [self.team.slug]} self.get_success_response(self.organization.slug, **data, status_code=201) - with Feature({"organizations:invite-members": False}): - data = {"email": user.email, "role": "billing", "teams": [self.team.slug]} - self.get_success_response(self.organization.slug, **data, status_code=201) - def test_no_team_invites(self): data = {"email": "eric@localhost", "role": "owner", "teams": []} response = self.get_success_response(self.organization.slug, **data) From 606ee708e9cfc738b95c326acd0c8867ca41ed72 Mon Sep 17 00:00:00 2001 From: Jarrett Scott Date: Thu, 7 Nov 2024 10:09:29 -0800 Subject: [PATCH 09/17] Separating frontend and backend changes --- src/sentry/api/endpoints/organization_member/index.py | 4 ++++ tests/sentry/api/endpoints/test_organization_member_index.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/sentry/api/endpoints/organization_member/index.py b/src/sentry/api/endpoints/organization_member/index.py index 201ec023c28ec..d92ac5988194c 100644 --- a/src/sentry/api/endpoints/organization_member/index.py +++ b/src/sentry/api/endpoints/organization_member/index.py @@ -314,6 +314,10 @@ def post(self, request: Request, organization) -> Response: """ Add or invite a member to an organization. """ + if 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") diff --git a/tests/sentry/api/endpoints/test_organization_member_index.py b/tests/sentry/api/endpoints/test_organization_member_index.py index ed95dd8041a90..3ece837da375c 100644 --- a/tests/sentry/api/endpoints/test_organization_member_index.py +++ b/tests/sentry/api/endpoints/test_organization_member_index.py @@ -550,12 +550,12 @@ def test_admin_invites(self): def test_member_invites(self): self.invite_all_helper("member") - def test_ignores_feature_flag(self): + def test_respects_feature_flag(self): user = self.create_user("baz@example.com") with Feature({"organizations:invite-members": False}): data = {"email": user.email, "role": "member", "teams": [self.team.slug]} - self.get_success_response(self.organization.slug, **data, status_code=201) + self.get_error_response(self.organization.slug, **data, status_code=403) def test_no_team_invites(self): data = {"email": "eric@localhost", "role": "owner", "teams": []} From f08119c3df77b60d6934da4b0b5a0e96414b44e6 Mon Sep 17 00:00:00 2001 From: Jarrett Scott Date: Sun, 10 Nov 2024 15:41:44 -0800 Subject: [PATCH 10/17] Billing orgRole validation --- .../api/endpoints/organization_member/index.py | 9 +++++++-- src/sentry/conf/server.py | 8 ++++++++ .../inviteMembersModal/inviteRowControl.tsx | 18 +++++++++++++++--- static/app/constants/index.tsx | 8 ++++++++ .../test_organization_member_index.py | 7 +++++++ 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/sentry/api/endpoints/organization_member/index.py b/src/sentry/api/endpoints/organization_member/index.py index d92ac5988194c..693a3ee34c85c 100644 --- a/src/sentry/api/endpoints/organization_member/index.py +++ b/src/sentry/api/endpoints/organization_member/index.py @@ -137,6 +137,8 @@ def validate_role(self, role): return self.validate_orgRole(role) def validate_orgRole(self, role): + if role == "billing": + 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 +316,16 @@ def post(self, request: Request, organization) -> Response: """ 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") + + if assigned_org_role != "billing" 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: diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 6a95383d8b988..63a59acb9cd9b 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -1990,6 +1990,14 @@ def custom_parameter_sort(parameter: dict) -> tuple[str, int]: # that is earlier in the chain cannot manage the settings of a member later # in the chain (they still require the appropriate scope). SENTRY_ROLES: tuple[RoleDict, ...] = ( + { + "id": "billing", + "name": "Billing", + "desc": "Can manage subscription and billing details.", + "scopes": {"org:billing"}, + "is_team_roles_allowed": False, + "is_retired": False, + }, { "id": "member", "name": "Member", diff --git a/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx b/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx index c8a24e5e4cd5c..b1ea9d9a86f39 100644 --- a/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx +++ b/static/app/components/modals/inviteMembersModal/inviteRowControl.tsx @@ -92,6 +92,18 @@ function InviteRowControl({ } }; + const billingRole = 'billing'; + const billingRoleOptions = [ + { + id: 'billing', + name: 'Billing', + isAllowed: true, + desc: 'Can manage subscription and billing details.', + minimumTeamRole: 'admin', + isTeamRolesAllowed: false, + }, + ]; + return (
  • { onChangeRole(roleOption); diff --git a/static/app/constants/index.tsx b/static/app/constants/index.tsx index d06f4941e8a4b..b097ceffb4f57 100644 --- a/static/app/constants/index.tsx +++ b/static/app/constants/index.tsx @@ -80,6 +80,14 @@ export const ALLOWED_SCOPES = [ // the members endpoint (primarily in cases where a user is admining a // different organization they are not a OrganizationMember of ). export const ORG_ROLES: OrgRole[] = [ + { + id: 'billing', + name: 'Billing', + isAllowed: true, + desc: 'Can manage subscription and billing details.', + minimumTeamRole: 'admin', + isTeamRolesAllowed: false, + }, { id: 'member', name: 'Member', diff --git a/tests/sentry/api/endpoints/test_organization_member_index.py b/tests/sentry/api/endpoints/test_organization_member_index.py index 3ece837da375c..0d23015091d98 100644 --- a/tests/sentry/api/endpoints/test_organization_member_index.py +++ b/tests/sentry/api/endpoints/test_organization_member_index.py @@ -557,6 +557,13 @@ def test_respects_feature_flag(self): data = {"email": user.email, "role": "member", "teams": [self.team.slug]} self.get_error_response(self.organization.slug, **data, status_code=403) + def test_allows_billing_members(self): + user = self.create_user("billing@example.com") + + with Feature({"organizations:invite-members": False}): + data = {"email": user.email, "role": "billing", "teams": []} + self.get_success_response(self.organization.slug, **data, status_code=201) + def test_no_team_invites(self): data = {"email": "eric@localhost", "role": "owner", "teams": []} response = self.get_success_response(self.organization.slug, **data) From ba890569e3c2a357c56a4e3bebe0bd206221a40a Mon Sep 17 00:00:00 2001 From: Jarrett Scott Date: Sun, 17 Nov 2024 21:52:21 -0800 Subject: [PATCH 11/17] Allow only one new billing member --- .../modals/inviteMembersModal/index.tsx | 11 ++++- .../inviteMembersModalview.tsx | 49 +++++++++++-------- .../inviteMembersModal/inviteRowControl.tsx | 20 ++------ 3 files changed, 42 insertions(+), 38 deletions(-) diff --git a/static/app/components/modals/inviteMembersModal/index.tsx b/static/app/components/modals/inviteMembersModal/index.tsx index 4edcda024fd1f..f37d99381aa57 100644 --- a/static/app/components/modals/inviteMembersModal/index.tsx +++ b/static/app/components/modals/inviteMembersModal/index.tsx @@ -75,6 +75,15 @@ function InviteMembersModal({ ); } + const hasOwnerRole = + (Array.isArray(memberResult.data) && + memberResult.data.some(member => member.orgRole === 'owner')) || + memberResult.data?.orgRole === 'owner'; + const hasBillingRole = + (Array.isArray(memberResult.data) && + memberResult.data.some(member => member.orgRole === 'billing')) || + memberResult.data?.orgRole === 'billing'; + return ( { trackAnalytics('invite_modal.closed', { organization, diff --git a/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx b/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx index 0fc416292a1ae..13a33a6f59b51 100644 --- a/static/app/components/modals/inviteMembersModal/inviteMembersModalview.tsx +++ b/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'; @@ -78,6 +77,16 @@ export default function InviteMembersModalView({ ) : null; + const canSendRef = useRef(canSend); + + useEffect(() => { + if (isOverMemberLimit) { + setRole('billing', 0); + setTeams([], 0); + canSendRef.current = true; + } + }); + return ( {errorAlert} @@ -121,17 +130,17 @@ export default function InviteMembersModalView({ /> ))} - - } - > - {t('Add another')} - - + {!isOverMemberLimit && ( + } + > + {t('Add another')} + + )}