edit-member.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. import { useState, useEffect, useMemo } from 'react'
  2. import { Trans, useTranslation } from 'react-i18next'
  3. import { useShareProjectContext } from './share-project-modal'
  4. import TransferOwnershipModal from './transfer-ownership-modal'
  5. import { removeMemberFromProject, updateMember } from '../utils/api'
  6. import { useProjectContext } from '@/shared/context/project-context'
  7. import { sendMB } from '@/infrastructure/event-tracking'
  8. import { Select } from '@/shared/components/select'
  9. import type { ProjectMember } from '@/shared/context/types/project-metadata'
  10. import { PermissionsLevel } from '@/features/ide-react/types/permissions'
  11. import { linkSharingEnforcementDate } from '../utils/link-sharing'
  12. import OLButton from '@/shared/components/ol/ol-button'
  13. import OLFormGroup from '@/shared/components/ol/ol-form-group'
  14. import OLCol from '@/shared/components/ol/ol-col'
  15. import MaterialIcon from '@/shared/components/material-icon'
  16. import { useUserContext } from '@/shared/context/user-context'
  17. import { upgradePlan } from '@/main/account-upgrade'
  18. import ShareProjectModalRow from '@/features/share-project-modal/components/share-project-modal-row'
  19. import {
  20. Dropdown,
  21. DropdownMenu,
  22. DropdownToggle,
  23. } from '@/shared/components/dropdown/dropdown-menu'
  24. import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
  25. import { useFeatureFlag } from '@/shared/context/split-test-context'
  26. import classnames from 'classnames'
  27. type PermissionsOption = PermissionsLevel | 'removeAccess' | 'downgraded'
  28. type EditMemberProps = {
  29. member: ProjectMember
  30. hasExceededCollaboratorLimit: boolean
  31. hasBeenDowngraded: boolean
  32. canAddCollaborators: boolean
  33. isReviewerOnFreeProject?: boolean
  34. }
  35. type Privilege = {
  36. key: PermissionsOption
  37. label: string
  38. }
  39. export default function EditMember({
  40. member,
  41. hasExceededCollaboratorLimit,
  42. hasBeenDowngraded,
  43. canAddCollaborators,
  44. isReviewerOnFreeProject,
  45. }: EditMemberProps) {
  46. const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
  47. const [privileges, setPrivileges] = useState<PermissionsOption>(
  48. member.privileges
  49. )
  50. const [confirmingOwnershipTransfer, setConfirmingOwnershipTransfer] =
  51. useState(false)
  52. const [privilegeChangePending, setPrivilegeChangePending] = useState(false)
  53. const { t } = useTranslation()
  54. // update the local state if the member's privileges change externally
  55. useEffect(() => {
  56. setPrivileges(member.privileges)
  57. }, [member.privileges])
  58. const { monitorRequest, setSuccessActionMessage } = useShareProjectContext()
  59. const { projectId, project, updateProject, features } = useProjectContext()
  60. const { members, invites } = project || {}
  61. const user = useUserContext()
  62. // Immediately commit this change if it's lower impact (eg. editor > viewer)
  63. // but show a confirmation button for removing access
  64. function handlePrivilegeChange(newPrivileges: PermissionsOption) {
  65. sendMB('collaborator-role-change', {
  66. previousMode: member.privileges,
  67. newMode: newPrivileges,
  68. ownerId: user.id,
  69. })
  70. setPrivileges(newPrivileges)
  71. if (newPrivileges !== 'removeAccess') {
  72. commitPrivilegeChange(newPrivileges)
  73. } else {
  74. setPrivilegeChangePending(true)
  75. }
  76. }
  77. function shouldWarnMember() {
  78. return (
  79. hasExceededCollaboratorLimit &&
  80. ['readAndWrite', 'review'].includes(privileges)
  81. )
  82. }
  83. function commitPrivilegeChange(newPrivileges: PermissionsOption) {
  84. setPrivileges(newPrivileges)
  85. setPrivilegeChangePending(false)
  86. if (newPrivileges === 'owner') {
  87. setConfirmingOwnershipTransfer(true)
  88. } else if (newPrivileges === 'removeAccess') {
  89. monitorRequest(() => removeMemberFromProject(projectId, member)).then(
  90. () => {
  91. const updatedMembers =
  92. members?.filter(existing => existing !== member) || []
  93. updateProject({
  94. members: updatedMembers,
  95. })
  96. sendMB('collaborator-removed', {
  97. project_id: projectId,
  98. current_collaborators_amount: updatedMembers.length,
  99. current_invites_amount: invites?.length || 0,
  100. })
  101. }
  102. )
  103. } else if (
  104. newPrivileges === 'readAndWrite' ||
  105. newPrivileges === 'review' ||
  106. newPrivileges === 'readOnly'
  107. ) {
  108. monitorRequest(() =>
  109. updateMember(projectId, member, {
  110. privilegeLevel: newPrivileges,
  111. })
  112. ).then(() => {
  113. updateProject({
  114. members:
  115. members?.map(
  116. (item): ProjectMember =>
  117. item._id === member._id
  118. ? { ...item, privileges: newPrivileges }
  119. : item
  120. ) || [],
  121. })
  122. setSuccessActionMessage(t('access_updated'))
  123. })
  124. }
  125. }
  126. if (confirmingOwnershipTransfer) {
  127. return (
  128. <TransferOwnershipModal
  129. member={member}
  130. cancel={() => {
  131. setConfirmingOwnershipTransfer(false)
  132. setPrivileges(member.privileges)
  133. }}
  134. />
  135. )
  136. }
  137. const confirmRemoval =
  138. privileges !== member.privileges && privilegeChangePending
  139. const getDropdownLabel = () => {
  140. if (hasBeenDowngraded) {
  141. return t('select_access_level')
  142. }
  143. switch (privileges) {
  144. case 'owner':
  145. return t('make_owner')
  146. case 'readAndWrite':
  147. return t('editor')
  148. case 'review':
  149. return t('reviewer')
  150. case 'readOnly':
  151. return t('viewer')
  152. case 'removeAccess':
  153. return t('remove_access')
  154. case 'downgraded':
  155. return t('select_access_level')
  156. default:
  157. return t('remove_access')
  158. }
  159. }
  160. const getPrivilegeSubtitle = (privilege: PermissionsOption) => {
  161. if (!['readAndWrite', 'review'].includes(privilege)) {
  162. return
  163. }
  164. if (
  165. (hasBeenDowngraded && !confirmRemoval) ||
  166. (!canAddCollaborators && !['readAndWrite', 'review'].includes(privileges))
  167. ) {
  168. return t('limited_to_n_collaborators_per_project', {
  169. count: features.collaborators,
  170. })
  171. }
  172. }
  173. const isPrivilegeDisabled = (privilege: PermissionsOption) => {
  174. return (
  175. !canAddCollaborators &&
  176. ['readAndWrite', 'review'].includes(privilege) &&
  177. ((hasBeenDowngraded && !confirmRemoval) ||
  178. !['readAndWrite', 'review'].includes(privileges))
  179. )
  180. }
  181. return isSharingUpdatesEnabled ? (
  182. <ShareProjectModalRow>
  183. <div className="d-inline-flex align-items-center h5 m-0 gap-2">
  184. <MaterialIcon
  185. type={
  186. shouldWarnMember() || member.pendingEditor || member.pendingReviewer
  187. ? 'warning'
  188. : 'person'
  189. }
  190. className={
  191. shouldWarnMember() || member.pendingEditor || member.pendingReviewer
  192. ? 'text-warning'
  193. : undefined
  194. }
  195. unfilled
  196. />
  197. <div className="px-2">
  198. {member.email}
  199. {member.pendingEditor && (
  200. <div className="small fw-normal">{t('view_only_downgraded')}</div>
  201. )}
  202. {member.pendingReviewer && (
  203. <div className="small fw-normal">
  204. {t('view_only_reviewer_downgraded')}
  205. </div>
  206. )}
  207. {shouldWarnMember() && (
  208. <div className="small fw-normal">
  209. {t('will_lose_edit_access_on_date', {
  210. date: linkSharingEnforcementDate,
  211. })}
  212. </div>
  213. )}
  214. {isReviewerOnFreeProject && (
  215. <div className="small fw-normal">
  216. <Trans
  217. i18nKey="comment_only_upgrade_to_enable_track_changes"
  218. components={[
  219. // eslint-disable-next-line react/jsx-key
  220. <OLButton
  221. variant="link"
  222. className="btn-inline-link"
  223. onClick={() => upgradePlan('track-changes')}
  224. />,
  225. ]}
  226. />
  227. </div>
  228. )}
  229. </div>
  230. </div>
  231. <div className="text-end">
  232. <Dropdown
  233. align="end"
  234. onSelect={(eventKey: PermissionsOption) => {
  235. if (eventKey) {
  236. handlePrivilegeChange(eventKey)
  237. }
  238. }}
  239. >
  240. <div className="d-flex align-items-center gap-2">
  241. {hasBeenDowngraded && !confirmRemoval && (
  242. <MaterialIcon type="warning" unfilled className="text-warning" />
  243. )}
  244. <DropdownToggle
  245. variant="ghost"
  246. className="d-flex align-items-center gap-2 no-default-caret"
  247. >
  248. {getDropdownLabel()}
  249. <MaterialIcon type="keyboard_arrow_down" />
  250. </DropdownToggle>
  251. </div>
  252. <DropdownMenu>
  253. <OLDropdownMenuItem
  254. as="button"
  255. eventKey="owner"
  256. leadingIcon={<MaterialIcon type="person_edit" unfilled />}
  257. active={privileges === 'owner'}
  258. trailingIcon={privileges === 'owner' ? 'check' : undefined}
  259. >
  260. {t('make_owner')}
  261. </OLDropdownMenuItem>
  262. <OLDropdownMenuItem
  263. as="button"
  264. eventKey="readAndWrite"
  265. leadingIcon={<MaterialIcon type="edit" unfilled />}
  266. active={privileges === 'readAndWrite'}
  267. trailingIcon={privileges === 'readAndWrite' ? 'check' : undefined}
  268. disabled={isPrivilegeDisabled('readAndWrite')}
  269. description={getPrivilegeSubtitle('readAndWrite')}
  270. >
  271. {t('editor')}
  272. </OLDropdownMenuItem>
  273. {features.trackChangesVisible && (
  274. <OLDropdownMenuItem
  275. as="button"
  276. eventKey="review"
  277. leadingIcon={<MaterialIcon type="mode_comment" unfilled />}
  278. active={privileges === 'review'}
  279. trailingIcon={privileges === 'review' ? 'check' : undefined}
  280. disabled={isPrivilegeDisabled('review')}
  281. description={getPrivilegeSubtitle('review')}
  282. >
  283. {t('reviewer')}
  284. </OLDropdownMenuItem>
  285. )}
  286. <OLDropdownMenuItem
  287. as="button"
  288. eventKey="readOnly"
  289. leadingIcon={<MaterialIcon type="visibility" unfilled />}
  290. active={privileges === 'readOnly'}
  291. trailingIcon={privileges === 'readOnly' ? 'check' : undefined}
  292. >
  293. {t('viewer')}
  294. </OLDropdownMenuItem>
  295. <OLDropdownMenuItem
  296. as="button"
  297. eventKey="removeAccess"
  298. variant="danger"
  299. leadingIcon={<MaterialIcon type="block" unfilled />}
  300. active={privileges === 'removeAccess'}
  301. trailingIcon={privileges === 'removeAccess' ? 'check' : undefined}
  302. >
  303. {t('remove_access')}
  304. </OLDropdownMenuItem>
  305. </DropdownMenu>
  306. </Dropdown>
  307. {confirmRemoval && (
  308. <form
  309. onSubmit={e => {
  310. e.preventDefault()
  311. if (privilegeChangePending) {
  312. commitPrivilegeChange(privileges)
  313. }
  314. }}
  315. >
  316. <ChangePrivilegesActions
  317. handleReset={() => setPrivileges(member.privileges)}
  318. />
  319. </form>
  320. )}
  321. </div>
  322. </ShareProjectModalRow>
  323. ) : (
  324. <form
  325. id="share-project-form"
  326. onSubmit={e => {
  327. e.preventDefault()
  328. if (privilegeChangePending) {
  329. commitPrivilegeChange(privileges)
  330. }
  331. }}
  332. >
  333. <OLFormGroup className="project-member row">
  334. <OLCol xs={8}>
  335. <div className="project-member-email-icon">
  336. <MaterialIcon
  337. type={
  338. shouldWarnMember() ||
  339. member.pendingEditor ||
  340. member.pendingReviewer
  341. ? 'warning'
  342. : 'person'
  343. }
  344. className={
  345. shouldWarnMember() ||
  346. member.pendingEditor ||
  347. member.pendingReviewer
  348. ? 'project-member-warning'
  349. : undefined
  350. }
  351. />
  352. <div className="email-warning">
  353. {member.email}
  354. {member.pendingEditor && (
  355. <div className="subtitle">{t('view_only_downgraded')}</div>
  356. )}
  357. {member.pendingReviewer && (
  358. <div className="subtitle">
  359. {t('view_only_reviewer_downgraded')}
  360. </div>
  361. )}
  362. {shouldWarnMember() && (
  363. <div className="subtitle">
  364. {t('will_lose_edit_access_on_date', {
  365. date: linkSharingEnforcementDate,
  366. })}
  367. </div>
  368. )}
  369. {isReviewerOnFreeProject && (
  370. <div className="small">
  371. <Trans
  372. i18nKey="comment_only_upgrade_to_enable_track_changes"
  373. components={[
  374. // eslint-disable-next-line react/jsx-key
  375. <OLButton
  376. variant="link"
  377. className="btn-inline-link"
  378. onClick={() => upgradePlan('track-changes')}
  379. />,
  380. ]}
  381. />
  382. </div>
  383. )}
  384. </div>
  385. </div>
  386. </OLCol>
  387. <OLCol xs={4} className="project-member-actions">
  388. <div className="project-member-select">
  389. {hasBeenDowngraded && !confirmRemoval && (
  390. <MaterialIcon type="warning" className="project-member-warning" />
  391. )}
  392. <SelectPrivilege
  393. value={privileges}
  394. handleChange={value => {
  395. if (value) {
  396. handlePrivilegeChange(value.key)
  397. }
  398. }}
  399. hasBeenDowngraded={hasBeenDowngraded && !confirmRemoval}
  400. canAddCollaborators={canAddCollaborators}
  401. />
  402. </div>
  403. {confirmRemoval && (
  404. <ChangePrivilegesActions
  405. handleReset={() => setPrivileges(member.privileges)}
  406. />
  407. )}
  408. </OLCol>
  409. </OLFormGroup>
  410. </form>
  411. )
  412. }
  413. type SelectPrivilegeProps = {
  414. value: string
  415. handleChange: (item: Privilege | null | undefined) => void
  416. hasBeenDowngraded: boolean
  417. canAddCollaborators: boolean
  418. }
  419. function SelectPrivilege({
  420. value,
  421. handleChange,
  422. hasBeenDowngraded,
  423. canAddCollaborators,
  424. }: SelectPrivilegeProps) {
  425. const { t } = useTranslation()
  426. const { features } = useProjectContext()
  427. const privileges = useMemo(
  428. (): Privilege[] =>
  429. features.trackChangesVisible
  430. ? [
  431. { key: 'owner', label: t('make_owner') },
  432. { key: 'readAndWrite', label: t('editor') },
  433. { key: 'review', label: t('reviewer') },
  434. { key: 'readOnly', label: t('viewer') },
  435. { key: 'removeAccess', label: t('remove_access') },
  436. ]
  437. : [
  438. { key: 'owner', label: t('make_owner') },
  439. { key: 'readAndWrite', label: t('editor') },
  440. { key: 'readOnly', label: t('viewer') },
  441. { key: 'removeAccess', label: t('remove_access') },
  442. ],
  443. [features.trackChangesVisible, t]
  444. )
  445. const downgradedPseudoPrivilege: Privilege = {
  446. key: 'downgraded',
  447. label: t('select_access_level'),
  448. }
  449. function getPrivilegeSubtitle(privilege: PermissionsOption) {
  450. if (!['readAndWrite', 'review'].includes(privilege)) {
  451. return ''
  452. }
  453. if (
  454. hasBeenDowngraded ||
  455. (!canAddCollaborators && !['readAndWrite', 'review'].includes(value))
  456. ) {
  457. return t('limited_to_n_collaborators_per_project', {
  458. count: features.collaborators,
  459. })
  460. } else {
  461. return ''
  462. }
  463. }
  464. function isPrivilegeDisabled(privilege: PermissionsOption) {
  465. return (
  466. !canAddCollaborators &&
  467. ['readAndWrite', 'review'].includes(privilege) &&
  468. (hasBeenDowngraded || !['readAndWrite', 'review'].includes(value))
  469. )
  470. }
  471. return (
  472. <Select
  473. items={privileges}
  474. itemToKey={item => item.key}
  475. itemToString={item => (item ? item.label : '')}
  476. itemToSubtitle={item => (item ? getPrivilegeSubtitle(item.key) : '')}
  477. itemToDisabled={item => (item ? isPrivilegeDisabled(item.key) : false)}
  478. defaultItem={privileges.find(item => item.key === value)}
  479. selected={
  480. hasBeenDowngraded
  481. ? downgradedPseudoPrivilege
  482. : privileges.find(item => item.key === value)
  483. }
  484. name="privileges"
  485. onSelectedItemChanged={handleChange}
  486. selectedIcon
  487. />
  488. )
  489. }
  490. type ChangePrivilegesActionsProps = {
  491. handleReset: React.ComponentProps<typeof OLButton>['onClick']
  492. }
  493. function ChangePrivilegesActions({
  494. handleReset,
  495. }: ChangePrivilegesActionsProps) {
  496. const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
  497. const { t } = useTranslation()
  498. return (
  499. <div
  500. className={classnames('text-center d-flex gap-1 me-3', {
  501. 'mt-2 justify-content-end': isSharingUpdatesEnabled,
  502. })}
  503. >
  504. <OLButton
  505. type="button"
  506. size="sm"
  507. variant="secondary"
  508. onClick={handleReset}
  509. >
  510. {t('cancel')}
  511. </OLButton>
  512. <OLButton type="submit" size="sm" variant="primary">
  513. {t('confirm')}
  514. </OLButton>
  515. </div>
  516. )
  517. }