edit-member.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  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 '@/features/ui/components/ol/ol-button'
  13. import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
  14. import OLCol from '@/features/ui/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. type PermissionsOption = PermissionsLevel | 'removeAccess' | 'downgraded'
  19. type EditMemberProps = {
  20. member: ProjectMember
  21. hasExceededCollaboratorLimit: boolean
  22. hasBeenDowngraded: boolean
  23. canAddCollaborators: boolean
  24. isReviewerOnFreeProject?: boolean
  25. }
  26. type Privilege = {
  27. key: PermissionsOption
  28. label: string
  29. }
  30. export default function EditMember({
  31. member,
  32. hasExceededCollaboratorLimit,
  33. hasBeenDowngraded,
  34. canAddCollaborators,
  35. isReviewerOnFreeProject,
  36. }: EditMemberProps) {
  37. const [privileges, setPrivileges] = useState<PermissionsOption>(
  38. member.privileges
  39. )
  40. const [confirmingOwnershipTransfer, setConfirmingOwnershipTransfer] =
  41. useState(false)
  42. const [privilegeChangePending, setPrivilegeChangePending] = useState(false)
  43. const { t } = useTranslation()
  44. // update the local state if the member's privileges change externally
  45. useEffect(() => {
  46. setPrivileges(member.privileges)
  47. }, [member.privileges])
  48. const { monitorRequest } = useShareProjectContext()
  49. const { projectId, project, updateProject } = useProjectContext()
  50. const { members, invites } = project || {}
  51. const user = useUserContext()
  52. // Immediately commit this change if it's lower impact (eg. editor > viewer)
  53. // but show a confirmation button for removing access
  54. function handlePrivilegeChange(newPrivileges: PermissionsOption) {
  55. sendMB('collaborator-role-change', {
  56. previousMode: member.privileges,
  57. newMode: newPrivileges,
  58. ownerId: user.id,
  59. })
  60. setPrivileges(newPrivileges)
  61. if (newPrivileges !== 'removeAccess') {
  62. commitPrivilegeChange(newPrivileges)
  63. } else {
  64. setPrivilegeChangePending(true)
  65. }
  66. }
  67. function shouldWarnMember() {
  68. return (
  69. hasExceededCollaboratorLimit &&
  70. ['readAndWrite', 'review'].includes(privileges)
  71. )
  72. }
  73. function commitPrivilegeChange(newPrivileges: PermissionsOption) {
  74. setPrivileges(newPrivileges)
  75. setPrivilegeChangePending(false)
  76. if (newPrivileges === 'owner') {
  77. setConfirmingOwnershipTransfer(true)
  78. } else if (newPrivileges === 'removeAccess') {
  79. monitorRequest(() => removeMemberFromProject(projectId, member)).then(
  80. () => {
  81. const updatedMembers =
  82. members?.filter(existing => existing !== member) || []
  83. updateProject({
  84. members: updatedMembers,
  85. })
  86. sendMB('collaborator-removed', {
  87. project_id: projectId,
  88. current_collaborators_amount: updatedMembers.length,
  89. current_invites_amount: invites?.length || 0,
  90. })
  91. }
  92. )
  93. } else if (
  94. newPrivileges === 'readAndWrite' ||
  95. newPrivileges === 'review' ||
  96. newPrivileges === 'readOnly'
  97. ) {
  98. monitorRequest(() =>
  99. updateMember(projectId, member, {
  100. privilegeLevel: newPrivileges,
  101. })
  102. ).then(() => {
  103. updateProject({
  104. members:
  105. members?.map(item =>
  106. item._id === member._id ? { ...item, newPrivileges } : item
  107. ) || [],
  108. })
  109. })
  110. }
  111. }
  112. if (confirmingOwnershipTransfer) {
  113. return (
  114. <TransferOwnershipModal
  115. member={member}
  116. cancel={() => {
  117. setConfirmingOwnershipTransfer(false)
  118. setPrivileges(member.privileges)
  119. }}
  120. />
  121. )
  122. }
  123. const confirmRemoval =
  124. privileges !== member.privileges && privilegeChangePending
  125. return (
  126. <form
  127. id="share-project-form"
  128. onSubmit={e => {
  129. e.preventDefault()
  130. if (privilegeChangePending) {
  131. commitPrivilegeChange(privileges)
  132. }
  133. }}
  134. >
  135. <OLFormGroup className="project-member row">
  136. <OLCol xs={8}>
  137. <div className="project-member-email-icon">
  138. <MaterialIcon
  139. type={
  140. shouldWarnMember() ||
  141. member.pendingEditor ||
  142. member.pendingReviewer
  143. ? 'warning'
  144. : 'person'
  145. }
  146. className={
  147. shouldWarnMember() ||
  148. member.pendingEditor ||
  149. member.pendingReviewer
  150. ? 'project-member-warning'
  151. : undefined
  152. }
  153. />
  154. <div className="email-warning">
  155. {member.email}
  156. {member.pendingEditor && (
  157. <div className="subtitle">{t('view_only_downgraded')}</div>
  158. )}
  159. {member.pendingReviewer && (
  160. <div className="subtitle">
  161. {t('view_only_reviewer_downgraded')}
  162. </div>
  163. )}
  164. {shouldWarnMember() && (
  165. <div className="subtitle">
  166. {t('will_lose_edit_access_on_date', {
  167. date: linkSharingEnforcementDate,
  168. })}
  169. </div>
  170. )}
  171. {isReviewerOnFreeProject && (
  172. <div className="small">
  173. <Trans
  174. i18nKey="comment_only_upgrade_to_enable_track_changes"
  175. components={[
  176. // eslint-disable-next-line react/jsx-key
  177. <OLButton
  178. variant="link"
  179. className="btn-inline-link"
  180. onClick={() => upgradePlan('track-changes')}
  181. />,
  182. ]}
  183. />
  184. </div>
  185. )}
  186. </div>
  187. </div>
  188. </OLCol>
  189. <OLCol xs={4} className="project-member-actions">
  190. {confirmRemoval && (
  191. <ChangePrivilegesActions
  192. handleReset={() => setPrivileges(member.privileges)}
  193. />
  194. )}
  195. <div className="project-member-select">
  196. {hasBeenDowngraded && !confirmRemoval && (
  197. <MaterialIcon type="warning" className="project-member-warning" />
  198. )}
  199. <SelectPrivilege
  200. value={privileges}
  201. handleChange={value => {
  202. if (value) {
  203. handlePrivilegeChange(value.key)
  204. }
  205. }}
  206. hasBeenDowngraded={hasBeenDowngraded && !confirmRemoval}
  207. canAddCollaborators={canAddCollaborators}
  208. />
  209. </div>
  210. </OLCol>
  211. </OLFormGroup>
  212. </form>
  213. )
  214. }
  215. type SelectPrivilegeProps = {
  216. value: string
  217. handleChange: (item: Privilege | null | undefined) => void
  218. hasBeenDowngraded: boolean
  219. canAddCollaborators: boolean
  220. }
  221. function SelectPrivilege({
  222. value,
  223. handleChange,
  224. hasBeenDowngraded,
  225. canAddCollaborators,
  226. }: SelectPrivilegeProps) {
  227. const { t } = useTranslation()
  228. const { features } = useProjectContext()
  229. const privileges = useMemo(
  230. (): Privilege[] =>
  231. features.trackChangesVisible
  232. ? [
  233. { key: 'owner', label: t('make_owner') },
  234. { key: 'readAndWrite', label: t('editor') },
  235. { key: 'review', label: t('reviewer') },
  236. { key: 'readOnly', label: t('viewer') },
  237. { key: 'removeAccess', label: t('remove_access') },
  238. ]
  239. : [
  240. { key: 'owner', label: t('make_owner') },
  241. { key: 'readAndWrite', label: t('editor') },
  242. { key: 'readOnly', label: t('viewer') },
  243. { key: 'removeAccess', label: t('remove_access') },
  244. ],
  245. [features.trackChangesVisible, t]
  246. )
  247. const downgradedPseudoPrivilege: Privilege = {
  248. key: 'downgraded',
  249. label: t('select_access_level'),
  250. }
  251. function getPrivilegeSubtitle(privilege: PermissionsOption) {
  252. if (!['readAndWrite', 'review'].includes(privilege)) {
  253. return ''
  254. }
  255. if (
  256. hasBeenDowngraded ||
  257. (!canAddCollaborators && !['readAndWrite', 'review'].includes(value))
  258. ) {
  259. return t('limited_to_n_collaborators_per_project', {
  260. count: features.collaborators,
  261. })
  262. } else {
  263. return ''
  264. }
  265. }
  266. function isPrivilegeDisabled(privilege: PermissionsOption) {
  267. return (
  268. !canAddCollaborators &&
  269. ['readAndWrite', 'review'].includes(privilege) &&
  270. (hasBeenDowngraded || !['readAndWrite', 'review'].includes(value))
  271. )
  272. }
  273. return (
  274. <Select
  275. items={privileges}
  276. itemToKey={item => item.key}
  277. itemToString={item => (item ? item.label : '')}
  278. itemToSubtitle={item => (item ? getPrivilegeSubtitle(item.key) : '')}
  279. itemToDisabled={item => (item ? isPrivilegeDisabled(item.key) : false)}
  280. defaultItem={privileges.find(item => item.key === value)}
  281. selected={
  282. hasBeenDowngraded
  283. ? downgradedPseudoPrivilege
  284. : privileges.find(item => item.key === value)
  285. }
  286. name="privileges"
  287. onSelectedItemChanged={handleChange}
  288. selectedIcon
  289. />
  290. )
  291. }
  292. type ChangePrivilegesActionsProps = {
  293. handleReset: React.ComponentProps<typeof OLButton>['onClick']
  294. }
  295. function ChangePrivilegesActions({
  296. handleReset,
  297. }: ChangePrivilegesActionsProps) {
  298. const { t } = useTranslation()
  299. return (
  300. <div className="text-center">
  301. <OLButton type="submit" size="sm" variant="primary">
  302. {t('change_or_cancel-change')}
  303. </OLButton>
  304. <div className="text-sm">
  305. {t('change_or_cancel-or')}
  306. &nbsp;
  307. <OLButton
  308. variant="link"
  309. className="btn-inline-link"
  310. onClick={handleReset}
  311. >
  312. {t('change_or_cancel-cancel')}
  313. </OLButton>
  314. </div>
  315. </div>
  316. )
  317. }