import {Fragment} from 'react'; import {RouteComponentProps} from 'react-router'; import {ClassNames} from '@emotion/react'; import styled from '@emotion/styled'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {resendMemberInvite} from 'sentry/actionCreators/members'; import {redirectToRemainingOrganization} from 'sentry/actionCreators/organizations'; import {AsyncComponentState} from 'sentry/components/deprecatedAsyncComponent'; import EmptyMessage from 'sentry/components/emptyMessage'; import HookOrDefault from 'sentry/components/hookOrDefault'; import Pagination from 'sentry/components/pagination'; import Panel from 'sentry/components/panels/panel'; import PanelBody from 'sentry/components/panels/panelBody'; import PanelHeader from 'sentry/components/panels/panelHeader'; import {ORG_ROLES} from 'sentry/constants'; import {t, tct} from 'sentry/locale'; import ConfigStore from 'sentry/stores/configStore'; import {space} from 'sentry/styles/space'; import { Member, MemberRole, MissingMember, Organization, OrganizationAuthProvider, } from 'sentry/types'; import {trackAnalytics} from 'sentry/utils/analytics'; import routeTitleGen from 'sentry/utils/routeTitle'; import theme from 'sentry/utils/theme'; import withOrganization from 'sentry/utils/withOrganization'; import DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView'; import { RenderSearch, SearchWrapper, } from 'sentry/views/settings/components/defaultSearchBar'; import InviteBanner from 'sentry/views/settings/organizationMembers/inviteBanner'; import MembersFilter from './components/membersFilter'; import InviteRequestRow from './inviteRequestRow'; import OrganizationMemberRow from './organizationMemberRow'; interface Props extends RouteComponentProps<{}, {}> { organization: Organization; } interface State extends AsyncComponentState { authProvider: OrganizationAuthProvider | null; inviteRequests: Member[]; invited: {[key: string]: 'loading' | 'success' | null}; member: (Member & {roles: MemberRole[]}) | null; members: Member[]; missingMembers: {integration: string; users: MissingMember[]}[]; } const MemberListHeader = HookOrDefault({ hookName: 'component:member-list-header', defaultComponent: () => {t('Active Members')}, }); class OrganizationMembersList extends DeprecatedAsyncView { getDefaultState() { return { ...super.getDefaultState(), members: [], missingMembers: [], invited: {}, }; } onLoadAllEndpointsSuccess() { const {organization} = this.props; const {inviteRequests, members} = this.state; trackAnalytics('member_settings_page.loaded', { organization, num_members: members?.length, num_invite_requests: inviteRequests?.length, }); } getEndpoints(): ReturnType { const {organization} = this.props; return [ ['members', `/organizations/${organization.slug}/members/`, {}, {paginate: true}], [ 'member', `/organizations/${organization.slug}/members/me/`, {}, {allowError: error => error.status === 404}, ], [ 'authProvider', `/organizations/${organization.slug}/auth-provider/`, {}, {allowError: error => error.status === 403}, ], ['inviteRequests', `/organizations/${organization.slug}/invite-requests/`], // [ // 'missingMembers', // `/organizations/${organization.slug}/missing-members/`, // {}, // {allowError: error => error.status === 403}, // ], ]; } getTitle() { const orgId = this.props.organization.slug; return routeTitleGen(t('Members'), orgId, false); } removeMember = async (id: string) => { const {organization} = this.props; await this.api.requestPromise(`/organizations/${organization.slug}/members/${id}/`, { method: 'DELETE', data: {}, }); this.setState(state => ({ members: state.members.filter(({id: existingId}) => existingId !== id), })); }; handleRemove = async ({id, name}: Member) => { const {organization} = this.props; const {slug: orgName} = organization; try { await this.removeMember(id); } catch { addErrorMessage(tct('Error removing [name] from [orgName]', {name, orgName})); return; } addSuccessMessage(tct('Removed [name] from [orgName]', {name, orgName})); }; handleLeave = async ({id}: Member) => { const {organization} = this.props; const {slug: orgName} = organization; try { await this.removeMember(id); } catch { addErrorMessage(tct('Error leaving [orgName]', {orgName})); return; } redirectToRemainingOrganization({orgId: orgName, removeOrg: true}); addSuccessMessage(tct('You left [orgName]', {orgName})); }; handleSendInvite = async ({id, expired}) => { this.setState(state => ({ invited: {...state.invited, [id]: 'loading'}, })); const {organization} = this.props; try { await resendMemberInvite(this.api, { orgId: organization.slug, memberId: id, regenerate: expired, }); } catch { this.setState(state => ({invited: {...state.invited, [id]: null}})); addErrorMessage(t('Error sending invite')); return; } this.setState(state => ({invited: {...state.invited, [id]: 'success'}})); }; handleInviteMissingMember = async (email: string) => { const {organization} = this.props; try { await this.api.requestPromise( `/organizations/${organization.slug}/members/?referrer=github_nudge_invite`, { method: 'POST', data: {email}, } ); addSuccessMessage(tct('Sent invite to [email]', {email})); this.fetchMembersList(); this.setState(state => ({ missingMembers: state.missingMembers.map(integrationMissingMembers => ({ ...integrationMissingMembers, users: integrationMissingMembers.users.filter(member => member.email !== email), })), })); } catch { addErrorMessage(t('Error sending invite')); } }; fetchMembersList = async () => { const {organization} = this.props; try { const data = await this.api.requestPromise( `/organizations/${organization.slug}/members/`, { method: 'GET', data: {paginate: true}, } ); this.setState({members: data}); } catch { addErrorMessage(t('Error fetching members')); } }; updateInviteRequest = (id: string, data: Partial) => this.setState(state => { const inviteRequests = [...state.inviteRequests]; const inviteIndex = inviteRequests.findIndex(request => request.id === id); inviteRequests[inviteIndex] = {...inviteRequests[inviteIndex], ...data}; return {inviteRequests}; }); removeInviteRequest = (id: string) => this.setState(state => ({ inviteRequests: state.inviteRequests.filter(request => request.id !== id), })); handleInviteRequestAction = async ({ inviteRequest, method, data, successMessage, errorMessage, eventKey, }) => { const {organization} = this.props; this.setState(state => ({ inviteRequestBusy: {...state.inviteRequestBusy, [inviteRequest.id]: true}, })); try { await this.api.requestPromise( `/organizations/${organization.slug}/invite-requests/${inviteRequest.id}/`, { method, data, } ); this.removeInviteRequest(inviteRequest.id); addSuccessMessage(successMessage); trackAnalytics(eventKey, { member_id: parseInt(inviteRequest.id, 10), invite_status: inviteRequest.inviteStatus, organization, }); } catch { addErrorMessage(errorMessage); } this.setState(state => ({ inviteRequestBusy: {...state.inviteRequestBusy, [inviteRequest.id]: false}, })); }; handleInviteRequestApprove = (inviteRequest: Member) => { this.handleInviteRequestAction({ inviteRequest, method: 'PUT', data: { role: inviteRequest.role, teams: inviteRequest.teams, approve: 1, }, successMessage: tct('[email] has been invited', {email: inviteRequest.email}), errorMessage: tct('Error inviting [email]', {email: inviteRequest.email}), eventKey: 'invite_request.approved', }); }; handleInviteRequestDeny = (inviteRequest: Member) => { this.handleInviteRequestAction({ inviteRequest, method: 'DELETE', data: {}, successMessage: tct('Invite request for [email] denied', { email: inviteRequest.email, }), errorMessage: tct('Error denying invite request for [email]', { email: inviteRequest.email, }), eventKey: 'invite_request.denied', }); }; renderBody() { const {organization} = this.props; const { membersPageLinks, members, member: currentMember, inviteRequests, missingMembers, } = this.state; const {access} = organization; const canAddMembers = access.includes('member:write'); const canRemove = access.includes('member:admin'); const currentUser = ConfigStore.get('user'); // Find out if current user is the only owner const isOnlyOwner = !members.find( ({role, email, pending}) => role === 'owner' && email !== currentUser.email && !pending ); // Only admins/owners can remove members const requireLink = !!this.state.authProvider && this.state.authProvider.require_link; // eslint-disable-next-line react/prop-types const renderSearch: RenderSearch = ({defaultSearchBar, value, handleChange}) => ( handleChange(query)} /> {defaultSearchBar} ); const githubMissingMembers = missingMembers?.filter( integrationMissingMembers => integrationMissingMembers.integration === 'github' )[0]; return ( {({css}) => this.renderSearchInput({ updateRoute: true, placeholder: t('Search Members'), children: renderSearch, className: css` font-size: ${theme.fontSizeMedium}; `, }) } {inviteRequests && inviteRequests.length > 0 && (
{t('Pending Members')}
{t('Role')}
{t('Teams')}
{inviteRequests.map(inviteRequest => ( this.updateInviteRequest(inviteRequest.id, data)} /> ))}
)} {members.map(member => ( ))} {members.length === 0 && ( {t('No members found.')} )}
); } } const SearchWrapperWithFilter = styled(SearchWrapper)` display: grid; grid-template-columns: max-content 1fr; margin-top: 0; `; const StyledPanelItem = styled('div')` display: grid; grid-template-columns: minmax(150px, auto) minmax(100px, 140px) 420px; gap: ${space(2)}; align-items: center; width: 100%; `; export default withOrganization(OrganizationMembersList);