organizationMemberRow.tsx 8.9 KB

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