organizationMemberRow.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import {Fragment, PureComponent} from 'react';
  2. import {PlainRoute} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import UserAvatar from 'sentry/components/avatar/userAvatar';
  5. import Button from 'sentry/components/button';
  6. import Confirm from 'sentry/components/confirm';
  7. import HookOrDefault from 'sentry/components/hookOrDefault';
  8. import Link from 'sentry/components/links/link';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import {PanelItem} from 'sentry/components/panels';
  11. import {IconCheckmark, IconClose, IconFlag, IconMail, IconSubtract} from 'sentry/icons';
  12. import {t, tct} from 'sentry/locale';
  13. import space from 'sentry/styles/space';
  14. import {AvatarUser, Member} from 'sentry/types';
  15. import isMemberDisabledFromLimit from 'sentry/utils/isMemberDisabledFromLimit';
  16. import recreateRoute from 'sentry/utils/recreateRoute';
  17. type Props = {
  18. canAddMembers: boolean;
  19. canRemoveMembers: boolean;
  20. currentUser: AvatarUser;
  21. member: Member;
  22. memberCanLeave: boolean;
  23. onLeave: (member: Member) => void;
  24. onRemove: (member: Member) => void;
  25. onSendInvite: (member: Member) => void;
  26. orgName: string;
  27. params: Record<string, string>;
  28. requireLink: boolean;
  29. routes: PlainRoute[];
  30. status: '' | 'loading' | 'success' | 'error' | null;
  31. };
  32. type State = {
  33. busy: boolean;
  34. };
  35. const DisabledMemberTooltip = HookOrDefault({
  36. hookName: 'component:disabled-member-tooltip',
  37. defaultComponent: ({children}) => <Fragment>{children}</Fragment>,
  38. });
  39. export default class OrganizationMemberRow extends PureComponent<Props, State> {
  40. state: State = {
  41. busy: false,
  42. };
  43. handleRemove = () => {
  44. const {onRemove} = this.props;
  45. if (typeof onRemove !== 'function') {
  46. return;
  47. }
  48. this.setState({busy: true});
  49. onRemove(this.props.member);
  50. };
  51. handleLeave = () => {
  52. const {onLeave} = this.props;
  53. if (typeof onLeave !== 'function') {
  54. return;
  55. }
  56. this.setState({busy: true});
  57. onLeave(this.props.member);
  58. };
  59. handleSendInvite = () => {
  60. const {onSendInvite, member} = this.props;
  61. if (typeof onSendInvite !== 'function') {
  62. return;
  63. }
  64. onSendInvite(member);
  65. };
  66. renderMemberRole() {
  67. const {member} = this.props;
  68. const {roleName, pending, expired} = member;
  69. if (isMemberDisabledFromLimit(member)) {
  70. return <DisabledMemberTooltip>{t('Deactivated')}</DisabledMemberTooltip>;
  71. }
  72. if (pending) {
  73. return (
  74. <InvitedRole>
  75. <IconMail size="md" />
  76. {expired ? t('Expired Invite') : tct('Invited [roleName]', {roleName})}
  77. </InvitedRole>
  78. );
  79. }
  80. return roleName;
  81. }
  82. render() {
  83. const {
  84. params,
  85. routes,
  86. member,
  87. orgName,
  88. status,
  89. requireLink,
  90. memberCanLeave,
  91. currentUser,
  92. canRemoveMembers,
  93. canAddMembers,
  94. } = this.props;
  95. const {id, flags, email, name, pending, user} = member;
  96. // if member is not the only owner, they can leave
  97. const needsSso = !flags['sso:linked'] && requireLink;
  98. const isCurrentUser = currentUser.email === email;
  99. const showRemoveButton = !isCurrentUser;
  100. const showLeaveButton = isCurrentUser;
  101. const canRemoveMember = canRemoveMembers && !isCurrentUser;
  102. // member has a `user` property if they are registered with sentry
  103. // i.e. has accepted an invite to join org
  104. const has2fa = user && user.has2fa;
  105. const detailsUrl = recreateRoute(id, {routes, params});
  106. const isInviteSuccessful = status === 'success';
  107. const isInviting = status === 'loading';
  108. const showResendButton = pending || needsSso;
  109. return (
  110. <StyledPanelItem data-test-id={email}>
  111. <MemberHeading>
  112. <UserAvatar size={32} user={user ?? {id: email, email}} />
  113. <MemberDescription to={detailsUrl}>
  114. <h5 style={{margin: '0 0 3px'}}>
  115. <UserName>{name}</UserName>
  116. </h5>
  117. <Email>{email}</Email>
  118. </MemberDescription>
  119. </MemberHeading>
  120. <div data-test-id="member-role">{this.renderMemberRole()}</div>
  121. <div data-test-id="member-status">
  122. {showResendButton ? (
  123. <Fragment>
  124. {isInviting && (
  125. <LoadingContainer>
  126. <LoadingIndicator mini />
  127. </LoadingContainer>
  128. )}
  129. {isInviteSuccessful && <span>{t('Sent!')}</span>}
  130. {!isInviting && !isInviteSuccessful && (
  131. <Button
  132. disabled={!canAddMembers}
  133. priority="primary"
  134. size="sm"
  135. onClick={this.handleSendInvite}
  136. >
  137. {pending ? t('Resend invite') : t('Resend SSO link')}
  138. </Button>
  139. )}
  140. </Fragment>
  141. ) : (
  142. <AuthStatus>
  143. {has2fa ? (
  144. <IconCheckmark isCircled color="success" />
  145. ) : (
  146. <IconFlag color="error" />
  147. )}
  148. {has2fa ? t('2FA Enabled') : t('2FA Not Enabled')}
  149. </AuthStatus>
  150. )}
  151. </div>
  152. {showRemoveButton || showLeaveButton ? (
  153. <RightColumn>
  154. {showRemoveButton && canRemoveMember && (
  155. <Confirm
  156. message={tct('Are you sure you want to remove [name] from [orgName]?', {
  157. name,
  158. orgName,
  159. })}
  160. onConfirm={this.handleRemove}
  161. >
  162. <Button
  163. data-test-id="remove"
  164. icon={<IconSubtract isCircled size="xs" />}
  165. size="sm"
  166. busy={this.state.busy}
  167. >
  168. {t('Remove')}
  169. </Button>
  170. </Confirm>
  171. )}
  172. {showRemoveButton && !canRemoveMember && (
  173. <Button
  174. disabled
  175. size="sm"
  176. title={t('You do not have access to remove members')}
  177. icon={<IconSubtract isCircled size="xs" />}
  178. >
  179. {t('Remove')}
  180. </Button>
  181. )}
  182. {showLeaveButton && memberCanLeave && (
  183. <Confirm
  184. message={tct('Are you sure you want to leave [orgName]?', {
  185. orgName,
  186. })}
  187. onConfirm={this.handleLeave}
  188. >
  189. <Button priority="danger" size="sm" icon={<IconClose size="xs" />}>
  190. {t('Leave')}
  191. </Button>
  192. </Confirm>
  193. )}
  194. {showLeaveButton && !memberCanLeave && (
  195. <Button
  196. size="sm"
  197. icon={<IconClose size="xs" />}
  198. disabled
  199. title={t(
  200. 'You cannot leave this organization as you are the only organization owner.'
  201. )}
  202. >
  203. {t('Leave')}
  204. </Button>
  205. )}
  206. </RightColumn>
  207. ) : null}
  208. </StyledPanelItem>
  209. );
  210. }
  211. }
  212. const StyledPanelItem = styled(PanelItem)`
  213. display: grid;
  214. grid-template-columns: minmax(150px, 4fr) minmax(90px, 2fr) minmax(120px, 2fr) minmax(
  215. 100px,
  216. 1fr
  217. );
  218. gap: ${space(2)};
  219. align-items: center;
  220. `;
  221. // Force action button at the end to align to right
  222. const RightColumn = styled('div')`
  223. display: flex;
  224. justify-content: flex-end;
  225. `;
  226. const Section = styled('div')`
  227. display: inline-grid;
  228. grid-template-columns: max-content auto;
  229. gap: ${space(1)};
  230. align-items: center;
  231. `;
  232. const MemberHeading = styled(Section)``;
  233. const MemberDescription = styled(Link)`
  234. overflow: hidden;
  235. `;
  236. const UserName = styled('div')`
  237. display: block;
  238. overflow: hidden;
  239. font-size: ${p => p.theme.fontSizeMedium};
  240. text-overflow: ellipsis;
  241. `;
  242. const Email = styled('div')`
  243. color: ${p => p.theme.subText};
  244. font-size: ${p => p.theme.fontSizeSmall};
  245. overflow: hidden;
  246. text-overflow: ellipsis;
  247. `;
  248. const InvitedRole = styled(Section)``;
  249. const LoadingContainer = styled('div')`
  250. margin-top: 0;
  251. margin-bottom: ${space(1.5)};
  252. `;
  253. const AuthStatus = styled(Section)``;