edit-member.tsx 10 KB

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