project-access.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. import { useState } from 'react'
  2. import { useTranslation } from 'react-i18next'
  3. import ShareProjectModalRow from '@/features/share-project-modal/components/share-project-modal-row'
  4. import MaterialIcon from '@/shared/components/material-icon'
  5. import OLButton from '@/shared/components/ol/ol-button'
  6. import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
  7. import {
  8. Dropdown,
  9. DropdownDivider,
  10. DropdownItem,
  11. DropdownMenu,
  12. DropdownToggle,
  13. } from '@/shared/components/dropdown/dropdown-menu'
  14. import DropdownListItem from '@/shared/components/dropdown/dropdown-list-item'
  15. import LinkSharing from '@/features/share-project-modal/components/link-sharing'
  16. import { useEditorContext } from '@/shared/context/editor-context'
  17. import { useProjectContext } from '@/shared/context/project-context'
  18. import {
  19. SharingLinkData,
  20. SharingLinkPrivileges,
  21. setPublicAccessLevel,
  22. updateSharingLink,
  23. } from '../utils/api'
  24. import MemberPrivileges from '@/features/share-project-modal/components/member-privileges'
  25. import RemoveSharingLinksModal from '@/features/share-project-modal/components/remove-sharing-links-modal'
  26. import {
  27. ProjectAccessType,
  28. useShareProjectContext,
  29. } from '@/features/share-project-modal/components/share-project-modal'
  30. import { ExcludeStrict } from '@ol-types/utils'
  31. import getMeta from '@/utils/meta'
  32. import { useFeatureFlag } from '@/shared/context/split-test-context'
  33. import { sendMB } from '@/infrastructure/event-tracking'
  34. import { debugConsole } from '@/utils/debugging'
  35. type ProjectAccessProps = {
  36. setIsInvitedPeopleScreen: React.Dispatch<React.SetStateAction<boolean>>
  37. invitedPeopleCount: number
  38. }
  39. export type PendingAccessType = ExcludeStrict<
  40. ProjectAccessType,
  41. 'legacyLinkSharing'
  42. >
  43. function ProjectAccess({
  44. setIsInvitedPeopleScreen,
  45. invitedPeopleCount,
  46. }: ProjectAccessProps) {
  47. const { t } = useTranslation()
  48. const [pendingAccess, setPendingAccess] = useState<PendingAccessType | null>(
  49. null
  50. )
  51. const { isProjectOwner } = useEditorContext()
  52. const { activeProfessionalGroupSubscriptions } = getMeta('ol-user')
  53. const groupSharingEnabled = useFeatureFlag('group-link-sharing')
  54. const {
  55. monitorRequest,
  56. setSuccessActionMessage,
  57. projectAccess,
  58. setProjectAccess,
  59. sharingLinkData,
  60. setSharingLinkData,
  61. } = useShareProjectContext()
  62. const { projectId } = useProjectContext()
  63. const privileges = sharingLinkData?.privileges
  64. const handleAccessChange = (newAccess: PendingAccessType) => {
  65. setPendingAccess(null)
  66. let reqBody: Pick<SharingLinkData, 'privileges' | 'subscriptionId'>
  67. if (newAccess === 'onlyInvitedPeople') {
  68. reqBody = { privileges: false }
  69. } else if (newAccess.startsWith('anyoneInXyzWithTheLink')) {
  70. const newSubscriptionId = newAccess.split('.')[1]
  71. reqBody = {
  72. privileges: privileges || 'readOnly',
  73. subscriptionId: newSubscriptionId,
  74. }
  75. } else if (newAccess === 'anyoneWithTheLink') {
  76. reqBody = { privileges: privileges || 'readOnly' }
  77. } else {
  78. return
  79. }
  80. monitorRequest(async () => {
  81. const data = await updateSharingLink(projectId, reqBody)
  82. if (projectAccess === 'legacyLinkSharing') {
  83. await setPublicAccessLevel(projectId, 'private')
  84. }
  85. return data
  86. })
  87. .then(data => {
  88. setSharingLinkData(data)
  89. setProjectAccess(newAccess)
  90. setSuccessActionMessage(t('access_updated'))
  91. sendMB('sharing-link-set-permissions', {
  92. project_id: projectId,
  93. access_level: newAccess.split('.')[0],
  94. ...reqBody,
  95. })
  96. })
  97. .catch(debugConsole.error)
  98. }
  99. const onAccessSelect = (eventKey: ProjectAccessType) => {
  100. if (
  101. projectAccess === 'legacyLinkSharing' &&
  102. eventKey !== 'legacyLinkSharing'
  103. ) {
  104. // Legacy link sharing: show confirmation first
  105. setPendingAccess(eventKey as PendingAccessType)
  106. } else {
  107. // Non-legacy: fire request directly
  108. handleAccessChange(eventKey as PendingAccessType)
  109. }
  110. }
  111. const onPrivilegesChange = (
  112. eventKey: ExcludeStrict<SharingLinkPrivileges, false>
  113. ) => {
  114. monitorRequest(() =>
  115. updateSharingLink(projectId, {
  116. privileges: eventKey,
  117. subscriptionId: sharingLinkData?.subscriptionId,
  118. })
  119. )
  120. .then(data => {
  121. setSharingLinkData(data)
  122. setSuccessActionMessage(t('access_updated'))
  123. sendMB('sharing-link-set-permissions', {
  124. project_id: projectId,
  125. access_level: projectAccess?.split('.')[0],
  126. privileges: eventKey,
  127. subscriptionId: data.subscriptionId,
  128. })
  129. })
  130. .catch(debugConsole.error)
  131. }
  132. const getGroupLinkText = (id?: string) => {
  133. if (
  134. !id ||
  135. !activeProfessionalGroupSubscriptions ||
  136. activeProfessionalGroupSubscriptions.length === 0
  137. ) {
  138. return ''
  139. }
  140. const subscription = activeProfessionalGroupSubscriptions.find(
  141. sub => sub._id === id
  142. )
  143. if (subscription?.teamName) {
  144. return t('anyone_in_x_with_the_link', {
  145. groupName: subscription.teamName,
  146. })
  147. } else {
  148. return t('anyone_in_your_group_with_the_link')
  149. }
  150. }
  151. const getProjectAccessDropdownToggleText = () => {
  152. if (!projectAccess) return ''
  153. const [accessType, subscriptionId] = projectAccess.split('.')
  154. switch (accessType) {
  155. case 'legacyLinkSharing':
  156. return t('via_sharing_links_legacy')
  157. case 'onlyInvitedPeople':
  158. return t('only_invited_people')
  159. case 'anyoneInXyzWithTheLink':
  160. return getGroupLinkText(subscriptionId)
  161. case 'anyoneWithTheLink':
  162. return t('anyone_with_the_link')
  163. default:
  164. return ''
  165. }
  166. }
  167. return (
  168. <>
  169. <h3 className="h4 fw-normal mt-3 mb-2 pt-1">{t('project_access')}</h3>
  170. <ShareProjectModalRow>
  171. <div className="d-inline-flex align-items-center h5 m-0 gap-2">
  172. <MaterialIcon type="group" unfilled />
  173. <div className="px-2 fw-normal">
  174. {invitedPeopleCount > 1
  175. ? t('x_people_invited', { count: invitedPeopleCount })
  176. : t('no_one_invited_yet')}
  177. </div>
  178. </div>
  179. <OLButton
  180. variant="ghost"
  181. trailingIcon="chevron_right"
  182. onClick={() => setIsInvitedPeopleScreen(true)}
  183. >
  184. {t('manage_access')}
  185. </OLButton>
  186. </ShareProjectModalRow>
  187. {projectAccess && (
  188. <ShareProjectModalRow>
  189. <div className="d-inline-flex align-items-center h5 m-0">
  190. {projectAccess === 'legacyLinkSharing' && (
  191. <MaterialIcon type="link" />
  192. )}
  193. {projectAccess === 'onlyInvitedPeople' && (
  194. <MaterialIcon type="lock" unfilled />
  195. )}
  196. {projectAccess.startsWith('anyoneInXyzWithTheLink') && (
  197. <MaterialIcon type="domain" unfilled />
  198. )}
  199. {projectAccess === 'anyoneWithTheLink' && (
  200. <MaterialIcon type="globe" unfilled />
  201. )}
  202. <Dropdown onSelect={onAccessSelect}>
  203. <DropdownToggle
  204. variant="ghost"
  205. className="d-flex align-items-center gap-2 no-default-caret"
  206. >
  207. {getProjectAccessDropdownToggleText()}
  208. <MaterialIcon type="keyboard_arrow_down" />
  209. </DropdownToggle>
  210. <DropdownMenu>
  211. {projectAccess === 'legacyLinkSharing' && (
  212. <>
  213. <DropdownListItem className="d-flex align-items-center">
  214. <DropdownItem
  215. as="button"
  216. eventKey="legacyLinkSharing"
  217. leadingIcon={<MaterialIcon type="link" />}
  218. trailingIcon={
  219. projectAccess === 'legacyLinkSharing'
  220. ? 'check'
  221. : undefined
  222. }
  223. active={projectAccess === 'legacyLinkSharing'}
  224. >
  225. {t('via_sharing_links_legacy')}
  226. </DropdownItem>
  227. </DropdownListItem>
  228. <DropdownDivider />
  229. </>
  230. )}
  231. <DropdownListItem className="d-flex align-items-center">
  232. <DropdownItem
  233. as="button"
  234. eventKey="onlyInvitedPeople"
  235. leadingIcon={<MaterialIcon type="lock" unfilled />}
  236. trailingIcon={
  237. projectAccess === 'onlyInvitedPeople'
  238. ? 'check'
  239. : undefined
  240. }
  241. active={projectAccess === 'onlyInvitedPeople'}
  242. >
  243. {t('only_invited_people')}
  244. </DropdownItem>
  245. </DropdownListItem>
  246. {groupSharingEnabled &&
  247. activeProfessionalGroupSubscriptions &&
  248. activeProfessionalGroupSubscriptions.map(subscription => (
  249. <DropdownListItem
  250. className="d-flex align-items-center"
  251. key={subscription._id}
  252. >
  253. <DropdownItem
  254. as="button"
  255. eventKey={`anyoneInXyzWithTheLink.${subscription._id}`}
  256. leadingIcon={<MaterialIcon type="domain" unfilled />}
  257. trailingIcon={
  258. projectAccess ===
  259. `anyoneInXyzWithTheLink.${subscription._id}`
  260. ? 'check'
  261. : undefined
  262. }
  263. active={
  264. projectAccess ===
  265. `anyoneInXyzWithTheLink.${subscription._id}`
  266. }
  267. >
  268. {t('anyone_in_x_with_the_link', {
  269. groupName: subscription.teamName || 'your group',
  270. })}
  271. </DropdownItem>
  272. </DropdownListItem>
  273. ))}
  274. <DropdownListItem className="d-flex align-items-center gap-2">
  275. <DropdownItem
  276. as="button"
  277. eventKey="anyoneWithTheLink"
  278. leadingIcon={<MaterialIcon type="globe" unfilled />}
  279. trailingIcon={
  280. projectAccess === 'anyoneWithTheLink'
  281. ? 'check'
  282. : undefined
  283. }
  284. active={projectAccess === 'anyoneWithTheLink'}
  285. >
  286. {t('anyone_with_the_link')}
  287. </DropdownItem>
  288. </DropdownListItem>
  289. </DropdownMenu>
  290. </Dropdown>
  291. {pendingAccess && (
  292. <RemoveSharingLinksModal
  293. pendingAccess={pendingAccess}
  294. onCancel={() => setPendingAccess(null)}
  295. onConfirm={() => handleAccessChange(pendingAccess)}
  296. />
  297. )}
  298. </div>
  299. {projectAccess !== 'legacyLinkSharing' && privileges && (
  300. <Dropdown align="end" onSelect={onPrivilegesChange}>
  301. <DropdownToggle
  302. variant="ghost"
  303. className="d-flex align-items-center gap-2 no-default-caret"
  304. >
  305. <MemberPrivileges privileges={privileges} />
  306. <MaterialIcon type="keyboard_arrow_down" />
  307. </DropdownToggle>
  308. <DropdownMenu>
  309. <OLDropdownMenuItem
  310. as="button"
  311. eventKey="readAndWrite"
  312. leadingIcon={<MaterialIcon type="edit" unfilled />}
  313. active={privileges === 'readAndWrite'}
  314. trailingIcon={
  315. privileges === 'readAndWrite' ? 'check' : undefined
  316. }
  317. >
  318. {t('editor')}
  319. </OLDropdownMenuItem>
  320. <OLDropdownMenuItem
  321. as="button"
  322. eventKey="review"
  323. leadingIcon={<MaterialIcon type="mode_comment" unfilled />}
  324. active={privileges === 'review'}
  325. trailingIcon={privileges === 'review' ? 'check' : undefined}
  326. >
  327. {t('reviewer')}
  328. </OLDropdownMenuItem>
  329. <OLDropdownMenuItem
  330. as="button"
  331. eventKey="readOnly"
  332. leadingIcon={<MaterialIcon type="visibility" unfilled />}
  333. active={privileges === 'readOnly'}
  334. trailingIcon={privileges === 'readOnly' ? 'check' : undefined}
  335. >
  336. {t('viewer')}
  337. </OLDropdownMenuItem>
  338. </DropdownMenu>
  339. </Dropdown>
  340. )}
  341. </ShareProjectModalRow>
  342. )}
  343. {isProjectOwner && <LinkSharing />}
  344. </>
  345. )
  346. }
  347. export default ProjectAccess