Jelajahi Sumber

Merge pull request #33553 from overleaf/ii-share-modal-privileges-2

[web] Hook up project access controls and privileges to sharing link API

GitOrigin-RevId: 05407476340cabf1ab56a18008f9f3fa978b9d79
ilkin-overleaf 2 bulan lalu
induk
melakukan
952266da94

+ 0 - 1
services/web/frontend/extracted-translations.json

@@ -1331,7 +1331,6 @@
   "not_a_student": "",
   "not_managed": "",
   "not_now": "",
-  "not_permitted_by_your_organization": "",
   "notification": "",
   "notification_personal_and_group_subscriptions": "",
   "notification_project_invite_accepted_message": "",

+ 1 - 1
services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx

@@ -67,7 +67,7 @@ export default function LinkSharing() {
   }
 
   if (isSharingUpdatesEnabled) {
-    if (projectAccess === 'linkSharing') {
+    if (projectAccess === 'legacyLinkSharing') {
       return <ReadAndWriteTokenLinks />
     }
     return null

+ 89 - 57
services/web/frontend/js/features/share-project-modal/components/project-access.tsx

@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'
 import ShareProjectModalRow from '@/features/share-project-modal/components/share-project-modal-row'
 import MaterialIcon from '@/shared/components/material-icon'
 import OLButton from '@/shared/components/ol/ol-button'
-import OLTooltip from '@/shared/components/ol/ol-tooltip'
 import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item'
 import {
   Dropdown,
@@ -15,20 +14,30 @@ import {
 import DropdownListItem from '@/shared/components/dropdown/dropdown-list-item'
 import LinkSharing from '@/features/share-project-modal/components/link-sharing'
 import { useEditorContext } from '@/shared/context/editor-context'
-import { PermissionsLevel } from '@/features/ide-react/types/permissions'
+import { useProjectContext } from '@/shared/context/project-context'
+import {
+  SharingLinkData,
+  SharingLinkPrivileges,
+  setPublicAccessLevel,
+  updateSharingLink,
+} from '../utils/api'
 import MemberPrivileges from '@/features/share-project-modal/components/member-privileges'
 import RemoveSharingLinksModal from '@/features/share-project-modal/components/remove-sharing-links-modal'
 import {
   ProjectAccessType,
   useShareProjectContext,
 } from '@/features/share-project-modal/components/share-project-modal'
+import { ExcludeStrict } from '@ol-types/utils'
 
 type ProjectAccessProps = {
   setIsInvitedPeopleScreen: React.Dispatch<React.SetStateAction<boolean>>
   invitedPeopleCount: number
 }
 
-export type PendingAccessType = Exclude<ProjectAccessType, 'linkSharing'>
+export type PendingAccessType = ExcludeStrict<
+  ProjectAccessType,
+  'legacyLinkSharing'
+>
 
 function ProjectAccess({
   setIsInvitedPeopleScreen,
@@ -38,35 +47,62 @@ function ProjectAccess({
   const [pendingAccess, setPendingAccess] = useState<PendingAccessType | null>(
     null
   )
-  // TODO set initial state
-  const [privileges, setPrivileges] =
-    useState<Exclude<PermissionsLevel, 'owner'>>('readOnly')
   const { isProjectOwner } = useEditorContext()
-  // TODO set company name
-  const companyName = 'XYZ'
+  // TODO get the group name from backend
+  const groupName = 'your group'
 
   const {
     monitorRequest,
     setSuccessActionMessage,
     projectAccess,
     setProjectAccess,
+    sharingLinkData,
+    setSharingLinkData,
   } = useShareProjectContext()
+  const { projectId } = useProjectContext()
+
+  const privileges = sharingLinkData?.privileges
 
   const handleAccessChange = (newAccess: PendingAccessType) => {
     setPendingAccess(null)
 
-    monitorRequest(
-      () =>
-        // TODO: replace with real API call
-        new Promise(resolve => setTimeout(resolve, 1000))
-    ).then(() => {
+    let reqBody: Pick<SharingLinkData, 'privileges' | 'subscriptionId'>
+    if (newAccess === 'onlyInvitedPeople') {
+      reqBody = { privileges: false }
+    } else if (
+      newAccess === 'anyoneInXyzWithTheLink' &&
+      sharingLinkData?.subscriptionId
+    ) {
+      reqBody = {
+        privileges: privileges || 'readOnly',
+        subscriptionId: sharingLinkData.subscriptionId,
+      }
+    } else if (newAccess === 'anyoneWithTheLink') {
+      reqBody = { privileges: privileges || 'readOnly' }
+    } else {
+      return
+    }
+
+    monitorRequest(async () => {
+      const data = await updateSharingLink(projectId, reqBody)
+
+      if (projectAccess === 'legacyLinkSharing') {
+        await setPublicAccessLevel(projectId, 'private')
+      }
+
+      return data
+    }).then(data => {
+      setSharingLinkData(newAccess === 'onlyInvitedPeople' ? null : data)
       setProjectAccess(newAccess)
       setSuccessActionMessage(t('access_updated'))
     })
   }
 
   const onAccessSelect = (eventKey: ProjectAccessType) => {
-    if (projectAccess === 'linkSharing' && eventKey !== 'linkSharing') {
+    if (
+      projectAccess === 'legacyLinkSharing' &&
+      eventKey !== 'legacyLinkSharing'
+    ) {
       // Legacy link sharing: show confirmation first
       setPendingAccess(eventKey as PendingAccessType)
     } else {
@@ -75,25 +111,28 @@ function ProjectAccess({
     }
   }
 
-  const onPrivilegesChange = (eventKey: Exclude<PermissionsLevel, 'owner'>) => {
-    monitorRequest(
-      () =>
-        // TODO: replace with real API call
-        new Promise(resolve => setTimeout(resolve, 1000))
-    ).then(() => {
-      setPrivileges(eventKey)
+  const onPrivilegesChange = (
+    eventKey: ExcludeStrict<SharingLinkPrivileges, false>
+  ) => {
+    monitorRequest(() =>
+      updateSharingLink(projectId, {
+        privileges: eventKey,
+        subscriptionId: sharingLinkData?.subscriptionId,
+      })
+    ).then(data => {
+      setSharingLinkData(data)
       setSuccessActionMessage(t('access_updated'))
     })
   }
 
   const getProjectAccessDropdownToggleText = () => {
     switch (projectAccess) {
-      case 'linkSharing':
+      case 'legacyLinkSharing':
         return t('via_sharing_links_legacy')
       case 'onlyInvitedPeople':
         return t('only_invited_people')
       case 'anyoneInXyzWithTheLink':
-        return t('anyone_in_x_with_the_link', { companyName })
+        return t('anyone_in_x_with_the_link', { groupName })
       case 'anyoneWithTheLink':
         return t('anyone_with_the_link')
       default:
@@ -122,7 +161,9 @@ function ProjectAccess({
       {projectAccess && (
         <ShareProjectModalRow>
           <div className="d-inline-flex align-items-center h5 m-0">
-            {projectAccess === 'linkSharing' && <MaterialIcon type="link" />}
+            {projectAccess === 'legacyLinkSharing' && (
+              <MaterialIcon type="link" />
+            )}
             {projectAccess === 'onlyInvitedPeople' && (
               <MaterialIcon type="lock" unfilled />
             )}
@@ -141,17 +182,19 @@ function ProjectAccess({
                 <MaterialIcon type="keyboard_arrow_down" />
               </DropdownToggle>
               <DropdownMenu>
-                {projectAccess === 'linkSharing' && (
+                {projectAccess === 'legacyLinkSharing' && (
                   <>
                     <DropdownListItem className="d-flex align-items-center">
                       <DropdownItem
                         as="button"
-                        eventKey="linkSharing"
+                        eventKey="legacyLinkSharing"
                         leadingIcon={<MaterialIcon type="link" />}
                         trailingIcon={
-                          projectAccess === 'linkSharing' ? 'check' : undefined
+                          projectAccess === 'legacyLinkSharing'
+                            ? 'check'
+                            : undefined
                         }
-                        active={projectAccess === 'linkSharing'}
+                        active={projectAccess === 'legacyLinkSharing'}
                       >
                         {t('via_sharing_links_legacy')}
                       </DropdownItem>
@@ -174,21 +217,23 @@ function ProjectAccess({
                     {t('only_invited_people')}
                   </DropdownItem>
                 </DropdownListItem>
-                <DropdownListItem className="d-flex align-items-center">
-                  <DropdownItem
-                    as="button"
-                    eventKey="anyoneInXyzWithTheLink"
-                    leadingIcon={<MaterialIcon type="domain" unfilled />}
-                    trailingIcon={
-                      projectAccess === 'anyoneInXyzWithTheLink'
-                        ? 'check'
-                        : undefined
-                    }
-                    active={projectAccess === 'anyoneInXyzWithTheLink'}
-                  >
-                    {t('anyone_in_x_with_the_link', { companyName })}
-                  </DropdownItem>
-                </DropdownListItem>
+                {sharingLinkData?.subscriptionId && (
+                  <DropdownListItem className="d-flex align-items-center">
+                    <DropdownItem
+                      as="button"
+                      eventKey="anyoneInXyzWithTheLink"
+                      leadingIcon={<MaterialIcon type="domain" unfilled />}
+                      trailingIcon={
+                        projectAccess === 'anyoneInXyzWithTheLink'
+                          ? 'check'
+                          : undefined
+                      }
+                      active={projectAccess === 'anyoneInXyzWithTheLink'}
+                    >
+                      {t('anyone_in_x_with_the_link', { groupName })}
+                    </DropdownItem>
+                  </DropdownListItem>
+                )}
                 <DropdownListItem className="d-flex align-items-center gap-2">
                   <DropdownItem
                     as="button"
@@ -203,19 +248,6 @@ function ProjectAccess({
                   >
                     {t('anyone_with_the_link')}
                   </DropdownItem>
-                  <OLTooltip
-                    id="tooltip-anyone-with-link"
-                    description={t('not_permitted_by_your_organization')}
-                    overlayProps={{ placement: 'left' }}
-                  >
-                    <span style={{ cursor: 'default' }}>
-                      <MaterialIcon
-                        type="info"
-                        unfilled
-                        className="align-middle px-2"
-                      />
-                    </span>
-                  </OLTooltip>
                 </DropdownListItem>
               </DropdownMenu>
             </Dropdown>
@@ -227,7 +259,7 @@ function ProjectAccess({
               />
             )}
           </div>
-          {projectAccess !== 'linkSharing' && (
+          {projectAccess !== 'legacyLinkSharing' && privileges && (
             <Dropdown align="end" onSelect={onPrivilegesChange}>
               <DropdownToggle
                 variant="ghost"

+ 3 - 1
services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx

@@ -33,6 +33,7 @@ const ShareModalBody = lazy(() => import('./share-modal-body'))
 
 type ShareProjectModalContentProps = {
   cancel: () => void
+  onShow: () => void
   show: boolean
   animation: boolean
   inFlight: boolean
@@ -42,6 +43,7 @@ type ShareProjectModalContentProps = {
 
 export default function ShareProjectModalContent({
   show,
+  onShow,
   cancel,
   animation,
   inFlight,
@@ -49,7 +51,7 @@ export default function ShareProjectModalContent({
   projectName,
 }: ShareProjectModalContentProps) {
   return (
-    <OLModal show={show} onHide={cancel} animation={animation}>
+    <OLModal show={show} onShow={onShow} onHide={cancel} animation={animation}>
       <ShareProjectModalContentInnerWithErrorBoundary
         inFlight={inFlight}
         error={error}

+ 48 - 13
services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx

@@ -11,9 +11,14 @@ import { useSplitTestContext } from '@/shared/context/split-test-context'
 import { sendMB } from '@/infrastructure/event-tracking'
 import { useEditorContext } from '@/shared/context/editor-context'
 import customLocalStorage from '@/infrastructure/local-storage'
+import { FetchError } from '@/infrastructure/fetch-json'
+import {
+  getSharingLink,
+  SharingLinkData,
+} from '@/features/share-project-modal/utils/api'
 
 export type ProjectAccessType =
-  | 'linkSharing'
+  | 'legacyLinkSharing'
   | 'onlyInvitedPeople'
   | 'anyoneInXyzWithTheLink'
   | 'anyoneWithTheLink'
@@ -36,6 +41,8 @@ export type ShareProjectContextValue = {
   setProjectAccess: React.Dispatch<
     React.SetStateAction<ProjectAccessType | undefined>
   >
+  sharingLinkData: SharingLinkData | null
+  setSharingLinkData: (data: SharingLinkData | null) => void
 }
 
 const SHOW_MODAL_COOLDOWN_PERIOD = 24 * 60 * 60 * 1000 // 24 hours
@@ -72,6 +79,8 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
   const [inFlight, setInFlight] =
     useState<ShareProjectContextValue['inFlight']>(false)
   const [error, setError] = useState<ShareProjectContextValue['error']>()
+  const [sharingLinkData, setSharingLinkData] =
+    useState<SharingLinkData | null>(null)
   const [projectAccess, setProjectAccess] = useState<
     ProjectAccessType | undefined
   >()
@@ -83,18 +92,6 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
   const { isProjectOwner } = useEditorContext()
   const { publicAccessLevel } = project || {}
 
-  // TODO: handle initial state for projectAccess
-  useEffect(() => {
-    if (!projectAccess) {
-      if (publicAccessLevel === 'tokenBased') {
-        // consider a legacy link sharing is enabled
-        setProjectAccess('linkSharing')
-      } else {
-        setProjectAccess('onlyInvitedPeople')
-      }
-    }
-  }, [projectAccess, publicAccessLevel])
-
   const { splitTestVariants } = useSplitTestContext()
 
   // show the new share modal if project owner
@@ -149,6 +146,41 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
     }
   }, [show])
 
+  const handleShow = useCallback(async () => {
+    if (publicAccessLevel === 'tokenBased') {
+      setSharingLinkData(null)
+      setProjectAccess('legacyLinkSharing')
+      return
+    }
+
+    try {
+      const data = await getSharingLink(projectId)
+
+      setSharingLinkData(data)
+
+      if (!data.privileges) {
+        setProjectAccess('onlyInvitedPeople')
+      } else if (data.subscriptionId) {
+        setProjectAccess('anyoneInXyzWithTheLink')
+      } else {
+        setProjectAccess('anyoneWithTheLink')
+      }
+    } catch (error) {
+      if (error instanceof FetchError && error.response?.status === 404) {
+        setSharingLinkData(null)
+        setProjectAccess('onlyInvitedPeople')
+        return
+      }
+
+      const errorData = (error as { data?: Record<string, string> })?.data
+      setError(
+        errorData?.errorReason ||
+          errorData?.error ||
+          'generic_something_went_wrong'
+      )
+    }
+  }, [publicAccessLevel, projectId])
+
   // close the modal if not in flight
   const cancel = useCallback(() => {
     if (!inFlight) {
@@ -196,10 +228,13 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
         setSuccessActionMessage,
         projectAccess,
         setProjectAccess,
+        sharingLinkData,
+        setSharingLinkData,
       }}
     >
       <ShareProjectModalContent
         animation={animation}
+        onShow={handleShow}
         cancel={cancel}
         error={error}
         inFlight={inFlight}

+ 26 - 0
services/web/frontend/js/features/share-project-modal/utils/api.ts

@@ -9,6 +9,32 @@ import { executeV2Captcha } from './captcha'
 import getMeta from '@/utils/meta'
 import { PermissionsLevel } from '@/features/ide-react/types/permissions'
 
+export type SharingLinkPrivileges =
+  | 'readAndWrite'
+  | 'review'
+  | 'readOnly'
+  | false
+
+export type SharingLinkData = {
+  _id: string
+  token: string
+  privileges: SharingLinkPrivileges
+  subscriptionId?: string
+}
+
+export function getSharingLink(projectId: string) {
+  return getJSON<SharingLinkData>(`/project/${projectId}/sharing-link`)
+}
+
+export function updateSharingLink(
+  projectId: string,
+  data: Pick<SharingLinkData, 'privileges' | 'subscriptionId'>
+) {
+  return postJSON<SharingLinkData>(`/project/${projectId}/sharing-link`, {
+    body: data,
+  })
+}
+
 export function sendInvite(
   projectId: string,
   email: string,

+ 1 - 2
services/web/locales/en.json

@@ -203,7 +203,7 @@
   "anonymous": "Anonymous",
   "anyone": "Anyone",
   "anyone_in_x": "Anyone in __groupName__",
-  "anyone_in_x_with_the_link": "Anyone in __companyName__ with the link",
+  "anyone_in_x_with_the_link": "Anyone in __groupName__ with the link",
   "anyone_with_link_can_edit": "Anyone with this link can edit this project",
   "anyone_with_link_can_view": "Anyone with this link can view this project",
   "anyone_with_the_link": "Anyone with the link",
@@ -1733,7 +1733,6 @@
   "not_found_error_from_the_supplied_url": "The link to open this content on Overleaf pointed to a file that could not be found. If this keeps happening for links on a particular site, please report this to them.",
   "not_managed": "Not managed",
   "not_now": "Not now",
-  "not_permitted_by_your_organization": "Not permitted by your organization",
   "not_registered": "Not registered",
   "note_features_under_development": "<0>Please note</0> that features in this program are still being tested and actively developed. This means that they might <0>change</0>, be <0>removed</0> or <0>become part of a premium plan</0>",
   "notification": "Notification",

+ 64 - 0
services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx

@@ -998,6 +998,70 @@ describe('<ShareProjectModal/>', function () {
     })
   })
 
+  describe('sharing-updates feature flag enabled', function () {
+    beforeEach(function () {
+      window.metaAttributesCache.set('ol-splitTestVariants', {
+        'sharing-updates': 'enabled',
+      })
+    })
+
+    it('sets "Via sharing links (legacy)" when `publicAccessLevel` is `tokenBased`', async function () {
+      fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {})
+
+      renderWithEditorContext(
+        <ShareProjectModal {...modalProps} />,
+        createContextProps({ publicAccessLevel: 'tokenBased' })
+      )
+
+      await screen.findByText('Via sharing links (legacy)')
+    })
+
+    it('sets "Only invited people" when sharing-link returns 404', async function () {
+      fetchMock.get(
+        `/project/${shareModalProjectDefaults._id}/sharing-link`,
+        404
+      )
+
+      renderWithEditorContext(
+        <ShareProjectModal {...modalProps} />,
+        createContextProps({ publicAccessLevel: 'private' })
+      )
+
+      await screen.findByText('Only invited people')
+    })
+
+    it('sets "Anyone with the link" when sharing-link returns a link without `subscriptionId`', async function () {
+      fetchMock.get(`/project/${shareModalProjectDefaults._id}/sharing-link`, {
+        _id: 'link-id',
+        token: 'abc123',
+        privileges: 'readOnly',
+      })
+
+      renderWithEditorContext(
+        <ShareProjectModal {...modalProps} />,
+        createContextProps({ publicAccessLevel: 'private' })
+      )
+
+      await screen.findByText('Anyone with the link')
+    })
+
+    it('sets "Anyone in your group with the link" when sharing-link returns a link with subscriptionId', async function () {
+      fetchMock.get(`/project/${shareModalProjectDefaults._id}/sharing-link`, {
+        _id: 'link-id',
+        token: 'abc123',
+        privileges: 'readOnly',
+        subscriptionId: 'sub-123',
+      })
+
+      renderWithEditorContext(
+        <ShareProjectModal {...modalProps} />,
+        createContextProps({ publicAccessLevel: 'private' })
+      )
+
+      await screen.findByText('Anyone in your group with the link')
+    })
+  })
+
   it('allows an email address to be selected, removed, then re-added', async function () {
     renderWithEditorContext(
       <ShareProjectModal {...modalProps} />,

+ 2 - 0
services/web/types/utils.ts

@@ -21,6 +21,8 @@ export type MergeAndOverride<Parent, Own> = Own & Omit<Parent, keyof Own>
 
 export type Keys<T extends object> = (keyof T)[]
 
+export type ExcludeStrict<T, U extends T> = Exclude<T, U>
+
 /**
  * Helper to create type guards for literal unions
  *