Просмотр исходного кода

Merge pull request #23268 from overleaf/kh-rm-dead-link-sharing-code

[web] remove deprecated project sharing modal

GitOrigin-RevId: 7b25918363c27154e0000e9497847217f2317fce
Kristina 1 год назад
Родитель
Сommit
70a17768a3
39 измененных файлов с 420 добавлено и 2304 удалено
  1. 0 6
      services/web/frontend/extracted-translations.json
  2. 3 3
      services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx
  3. 0 0
      services/web/frontend/js/features/share-project-modal/components/access-levels-changed.tsx
  4. 61 91
      services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx
  5. 27 10
      services/web/frontend/js/features/share-project-modal/components/add-collaborators.jsx
  6. 0 0
      services/web/frontend/js/features/share-project-modal/components/collaborators-limit-upgrade.tsx
  7. 0 203
      services/web/frontend/js/features/share-project-modal/components/edit-member.jsx
  8. 5 3
      services/web/frontend/js/features/share-project-modal/components/edit-member.tsx
  9. 1 1
      services/web/frontend/js/features/share-project-modal/components/editor-over-limit-modal-content.tsx
  10. 0 0
      services/web/frontend/js/features/share-project-modal/components/editor-over-limit-modal.tsx
  11. 6 7
      services/web/frontend/js/features/share-project-modal/components/invite.jsx
  12. 55 26
      services/web/frontend/js/features/share-project-modal/components/link-sharing.jsx
  13. 14 3
      services/web/frontend/js/features/share-project-modal/components/owner-info.jsx
  14. 0 74
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators-upgrade.tsx
  15. 0 197
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators.jsx
  16. 0 142
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/invite.jsx
  17. 0 325
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/link-sharing.jsx
  18. 0 23
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/member-privileges.jsx
  19. 0 29
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/owner-info.jsx
  20. 0 419
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/select-collaborators.jsx
  21. 0 39
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites.jsx
  22. 0 128
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-modal-body.tsx
  23. 0 129
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal-content.tsx
  24. 0 179
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal.tsx
  25. 0 96
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/transfer-ownership-modal.jsx
  26. 0 34
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/view-member.jsx
  27. 7 4
      services/web/frontend/js/features/share-project-modal/components/select-collaborators.jsx
  28. 0 34
      services/web/frontend/js/features/share-project-modal/components/send-invites-notice.jsx
  29. 1 1
      services/web/frontend/js/features/share-project-modal/components/send-invites-notice.tsx
  30. 25 2
      services/web/frontend/js/features/share-project-modal/components/send-invites.jsx
  31. 75 12
      services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx
  32. 2 2
      services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx
  33. 44 5
      services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx
  34. 3 3
      services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.jsx
  35. 13 2
      services/web/frontend/js/features/share-project-modal/components/view-member.jsx
  36. 0 0
      services/web/frontend/js/features/share-project-modal/components/view-only-access-modal-content.tsx
  37. 0 0
      services/web/frontend/js/features/share-project-modal/components/view-only-access-modal.tsx
  38. 0 5
      services/web/locales/en.json
  39. 78 67
      services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx

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

@@ -72,7 +72,6 @@
   "add_error_assist_annual_to_your_projects": "",
   "add_error_assist_to_your_projects": "",
   "add_files": "",
-  "add_more_collaborators": "",
   "add_more_editors": "",
   "add_more_managers": "",
   "add_more_members": "",
@@ -123,7 +122,6 @@
   "all_these_experiments_are_available_exclusively": "",
   "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "",
   "already_subscribed_try_refreshing_the_page": "",
-  "also": "",
   "an_email_has_already_been_sent_to": "",
   "an_error_occured_while_restoring_project": "",
   "an_error_occurred_when_verifying_the_coupon_code": "",
@@ -866,7 +864,6 @@
   "link_accounts_and_add_email": "",
   "link_institutional_email_get_started": "",
   "link_sharing": "",
-  "link_sharing_is_off": "",
   "link_sharing_is_off_short": "",
   "link_sharing_is_on": "",
   "link_to_github": "",
@@ -986,7 +983,6 @@
   "need_more_than_x_licenses": "",
   "need_to_add_new_primary_before_remove": "",
   "need_to_leave": "",
-  "need_to_upgrade_for_more_collabs": "",
   "new_compile_domain_notice": "",
   "new_file": "",
   "new_folder": "",
@@ -1284,7 +1280,6 @@
   "remove": "",
   "remove_access": "",
   "remove_add_on": "",
-  "remove_collaborator": "",
   "remove_from_group": "",
   "remove_link": "",
   "remove_manager": "",
@@ -1448,7 +1443,6 @@
   "setup_another_account_under_a_personal_email_address": "",
   "share": "",
   "share_project": "",
-  "share_with_your_collabs": "",
   "shared_with_you": "",
   "sharelatex_beta_program": "",
   "shortcut_to_open_advanced_reference_search": "",

+ 3 - 3
services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx

@@ -3,9 +3,9 @@ import { useOnlineUsersContext } from '@/features/ide-react/context/online-users
 import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
 import * as eventTracking from '@/infrastructure/event-tracking'
 import EditorNavigationToolbarRoot from '@/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root'
-import ShareProjectModal from '@/features/share-project-modal/components/restricted-link-sharing/share-project-modal'
-import EditorOverLimitModal from '@/features/share-project-modal/components/restricted-link-sharing/editor-over-limit-modal'
-import ViewOnlyAccessModal from '@/features/share-project-modal/components/restricted-link-sharing/view-only-access-modal'
+import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal'
+import EditorOverLimitModal from '@/features/share-project-modal/components/editor-over-limit-modal'
+import ViewOnlyAccessModal from '@/features/share-project-modal/components/view-only-access-modal'
 
 function EditorNavigationToolbar() {
   const [showShareModal, setShowShareModal] = useState(false)

+ 0 - 0
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/access-levels-changed.tsx → services/web/frontend/js/features/share-project-modal/components/access-levels-changed.tsx


+ 61 - 91
services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx

@@ -1,104 +1,74 @@
-import { useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import { useUserContext } from '../../../shared/context/user-context'
+import Notification from '@/shared/components/notification'
 import { upgradePlan } from '@/main/account-upgrade'
-import StartFreeTrialButton from '../../../shared/components/start-free-trial-button'
-import Icon from '../../../shared/components/icon'
-import { useFeatureFlag } from '../../../shared/context/split-test-context'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import MaterialIcon from '@/shared/components/material-icon'
+import { linkSharingEnforcementDate } from '../utils/link-sharing'
+import { useProjectContext } from '@/shared/context/project-context'
+import { useUserContext } from '@/shared/context/user-context'
+import { sendMB } from '@/infrastructure/event-tracking'
+import StartFreeTrialButton from '@/shared/components/start-free-trial-button'
 import OLButton from '@/features/ui/components/ol/ol-button'
 
 export default function AddCollaboratorsUpgrade() {
   const { t } = useTranslation()
+  const { features } = useProjectContext()
   const user = useUserContext()
 
-  const [startedFreeTrial, setStartedFreeTrial] = useState(false)
-  const hasNewPaywallCta = useFeatureFlag('paywall-cta')
-
   return (
     <div className="add-collaborators-upgrade">
-      <p className="text-center">
-        {t('need_to_upgrade_for_more_collabs')}. {t('also')}:
-      </p>
-      <ul className="list-unstyled">
-        <li>
-          <BootstrapVersionSwitcher
-            bs3={<Icon type="check" />}
-            bs5={<MaterialIcon type="check" className="align-text-bottom" />}
-          />
-          &nbsp;
-          {t('unlimited_projects')}
-        </li>
-        <li>
-          <BootstrapVersionSwitcher
-            bs3={<Icon type="check" />}
-            bs5={<MaterialIcon type="check" className="align-text-bottom" />}
-          />
-          &nbsp;
-          {t('collabs_per_proj', {
-            collabcount: 'Multiple',
-          })}
-        </li>
-        <li>
-          <BootstrapVersionSwitcher
-            bs3={<Icon type="check" />}
-            bs5={<MaterialIcon type="check" className="align-text-bottom" />}
-          />
-          &nbsp;
-          {t('full_doc_history')}
-        </li>
-        <li>
-          <BootstrapVersionSwitcher
-            bs3={<Icon type="check" />}
-            bs5={<MaterialIcon type="check" className="align-text-bottom" />}
-          />
-          &nbsp;
-          {t('sync_to_dropbox')}
-        </li>
-        <li>
-          <BootstrapVersionSwitcher
-            bs3={<Icon type="check" />}
-            bs5={<MaterialIcon type="check" className="align-text-bottom" />}
-          />
-          &nbsp;
-          {t('sync_to_github')}
-        </li>
-        <li>
-          <BootstrapVersionSwitcher
-            bs3={<Icon type="check" />}
-            bs5={<MaterialIcon type="check" className="align-text-bottom" />}
-          />
-          &nbsp;
-          {t('compile_larger_projects')}
-        </li>
-      </ul>
-      <p className="text-center row-spaced-thin">
-        {user.allowedFreeTrial ? (
-          <StartFreeTrialButton
-            buttonProps={{ variant: 'primary' }}
-            handleClick={() => setStartedFreeTrial(true)}
-            source="project-sharing"
-          >
-            {hasNewPaywallCta
-              ? t('add_more_collaborators')
-              : t('start_free_trial')}
-          </StartFreeTrialButton>
-        ) : (
-          <OLButton
-            variant="primary"
-            onClick={() => {
-              upgradePlan('project-sharing')
-              setStartedFreeTrial(true)
-            }}
-          >
-            {t('upgrade')}
-          </OLButton>
-        )}
-      </p>
-      {startedFreeTrial && (
-        <p className="small">{t('refresh_page_after_starting_free_trial')}</p>
-      )}
+      <Notification
+        isActionBelowContent
+        type="warning"
+        title={t('editor_limit_exceeded_in_this_project')}
+        content={
+          <p>
+            {t('your_plan_is_limited_to_n_editors', {
+              count: features.collaborators,
+            })}{' '}
+            {t('from_enforcement_date', {
+              enforcementDate: linkSharingEnforcementDate,
+            })}
+          </p>
+        }
+        action={
+          <div className="upgrade-actions">
+            {user.allowedFreeTrial ? (
+              <StartFreeTrialButton
+                buttonProps={{ variant: 'secondary', size: 'sm' }}
+                source="project-sharing"
+                variant="exceeds"
+              >
+                {t('upgrade')}
+              </StartFreeTrialButton>
+            ) : (
+              <OLButton
+                variant="secondary"
+                size="sm"
+                onClick={() => {
+                  upgradePlan('project-sharing')
+                }}
+              >
+                {t('upgrade')}
+              </OLButton>
+            )}
+            <OLButton
+              variant="link"
+              size="sm"
+              href="https://www.overleaf.com/blog/changes-to-project-sharing"
+              target="_blank"
+              rel="noreferrer"
+              onClick={() => {
+                sendMB('paywall-info-click', {
+                  'paywall-type': 'project-sharing',
+                  content: 'blog',
+                  variant: 'exceeds',
+                })
+              }}
+            >
+              {t('read_more')}
+            </OLButton>
+          </div>
+        }
+      />
     </div>
   )
 }

+ 27 - 10
services/web/frontend/js/features/share-project-modal/components/add-collaborators.jsx

@@ -1,20 +1,22 @@
-import { useState, useMemo, useCallback } from 'react'
+import { useEffect, useState, useMemo, useCallback } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useMultipleSelection } from 'downshift'
 import { useShareProjectContext } from './share-project-modal'
 import SelectCollaborators from './select-collaborators'
 import { resendInvite, sendInvite } from '../utils/api'
 import { useUserContacts } from '../hooks/use-user-contacts'
-import useIsMounted from '../../../shared/hooks/use-is-mounted'
-import { useProjectContext } from '../../../shared/context/project-context'
-import { sendMB } from '../../../infrastructure/event-tracking'
+import useIsMounted from '@/shared/hooks/use-is-mounted'
+import { useProjectContext } from '@/shared/context/project-context'
+import { sendMB } from '@/infrastructure/event-tracking'
 import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
+import PropTypes from 'prop-types'
 import OLForm from '@/features/ui/components/ol/ol-form'
 import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
 import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
 import OLButton from '@/features/ui/components/ol/ol-button'
+import getMeta from '@/utils/meta'
 
-export default function AddCollaborators() {
+export default function AddCollaborators({ readOnly }) {
   const [privileges, setPrivileges] = useState('readAndWrite')
 
   const isMounted = useIsMounted()
@@ -49,6 +51,12 @@ export default function AddCollaborators() {
 
   const { reset, selectedItems } = multipleSelectionProps
 
+  useEffect(() => {
+    if (readOnly && privileges === 'readAndWrite') {
+      setPrivileges('readOnly')
+    }
+  }, [privileges, readOnly])
+
   const handleSubmit = useCallback(async () => {
     if (!selectedItems.length) {
       return
@@ -142,12 +150,12 @@ export default function AddCollaborators() {
   ])
 
   return (
-    <OLForm>
+    <OLForm className="add-collabs">
       <OLFormGroup>
         <SelectCollaborators
           loading={!nonMemberContacts}
           options={nonMemberContacts || []}
-          placeholder="joe@example.com, sue@example.com, …"
+          placeholder="Email, comma separated"
           multipleSelectionProps={multipleSelectionProps}
         />
       </OLFormGroup>
@@ -162,8 +170,13 @@ export default function AddCollaborators() {
               bsSize: 'sm',
             }}
           >
-            <option value="readAndWrite">{t('can_edit')}</option>
-            <option value="readOnly">{t('read_only')}</option>
+            <option disabled={readOnly} value="readAndWrite">
+              {t('can_edit')}
+            </option>
+            <option value="readOnly">{t('can_view')}</option>
+            {getMeta('ol-isReviewerRoleEnabled') && (
+              <option value="review">{t('can_review')}</option>
+            )}
           </OLFormSelect>
           <span>&nbsp;&nbsp;</span>
           <ClickableElementEnhancer
@@ -171,10 +184,14 @@ export default function AddCollaborators() {
             onClick={handleSubmit}
             variant="primary"
           >
-            {t('share')}
+            {t('invite')}
           </ClickableElementEnhancer>
         </div>
       </OLFormGroup>
     </OLForm>
   )
 }
+
+AddCollaborators.propTypes = {
+  readOnly: PropTypes.bool,
+}

+ 0 - 0
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/collaborators-limit-upgrade.tsx → services/web/frontend/js/features/share-project-modal/components/collaborators-limit-upgrade.tsx


+ 0 - 203
services/web/frontend/js/features/share-project-modal/components/edit-member.jsx

@@ -1,203 +0,0 @@
-import { useState, useEffect } from 'react'
-import PropTypes from 'prop-types'
-import { useTranslation } from 'react-i18next'
-import { useShareProjectContext } from './share-project-modal'
-import TransferOwnershipModal from './transfer-ownership-modal'
-import { removeMemberFromProject, updateMember } from '../utils/api'
-import Icon from '../../../shared/components/icon'
-import { useProjectContext } from '../../../shared/context/project-context'
-import { sendMB } from '../../../infrastructure/event-tracking'
-import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
-import OLCol from '@/features/ui/components/ol/ol-col'
-import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
-import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
-import OLButton from '@/features/ui/components/ol/ol-button'
-import MaterialIcon from '@/shared/components/material-icon'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { bsVersion } from '@/features/utils/bootstrap-5'
-
-export default function EditMember({ member }) {
-  const [privileges, setPrivileges] = useState(member.privileges)
-  const [confirmingOwnershipTransfer, setConfirmingOwnershipTransfer] =
-    useState(false)
-
-  // update the local state if the member's privileges change externally
-  useEffect(() => {
-    setPrivileges(member.privileges)
-  }, [member.privileges])
-
-  const { updateProject, monitorRequest } = useShareProjectContext()
-  const { _id: projectId, members } = useProjectContext()
-
-  function handleSubmit(event) {
-    event.preventDefault()
-
-    if (privileges === 'owner') {
-      setConfirmingOwnershipTransfer(true)
-    } else {
-      monitorRequest(() =>
-        updateMember(projectId, member, {
-          privilegeLevel: privileges,
-        })
-      ).then(() => {
-        updateProject({
-          members: members.map(item =>
-            item._id === member._id ? { ...item, privileges } : item
-          ),
-        })
-      })
-    }
-  }
-
-  if (confirmingOwnershipTransfer) {
-    return (
-      <TransferOwnershipModal
-        member={member}
-        cancel={() => setConfirmingOwnershipTransfer(false)}
-      />
-    )
-  }
-
-  return (
-    <form
-      id="share-project-form"
-      className={bsVersion({ bs3: 'form-horizontal' })}
-      onSubmit={handleSubmit}
-    >
-      <OLFormGroup className="project-member row">
-        <OLCol xs={7} className={bsVersion({ bs3: 'pt-1', bs5: 'pt-2' })}>
-          {member.email}
-        </OLCol>
-
-        <OLCol xs={3}>
-          <SelectPrivilege
-            value={privileges}
-            handleChange={event => setPrivileges(event.target.value)}
-          />
-        </OLCol>
-
-        <OLCol xs={2}>
-          {privileges === member.privileges ? (
-            <RemoveMemberAction member={member} />
-          ) : (
-            <ChangePrivilegesActions
-              handleReset={() => setPrivileges(member.privileges)}
-            />
-          )}
-        </OLCol>
-      </OLFormGroup>
-    </form>
-  )
-}
-EditMember.propTypes = {
-  member: PropTypes.shape({
-    _id: PropTypes.string.isRequired,
-    email: PropTypes.string.isRequired,
-    privileges: PropTypes.string.isRequired,
-  }),
-}
-
-function SelectPrivilege({ value, handleChange }) {
-  const { t } = useTranslation()
-
-  return (
-    <OLFormSelect
-      className="privileges"
-      value={value}
-      onChange={handleChange}
-      bs3Props={{
-        bsSize: 'sm',
-      }}
-    >
-      <option value="owner">{t('owner')}</option>
-      <option value="readAndWrite">{t('can_edit')}</option>
-      <option value="readOnly">{t('read_only')}</option>
-    </OLFormSelect>
-  )
-}
-
-SelectPrivilege.propTypes = {
-  value: PropTypes.string.isRequired,
-  handleChange: PropTypes.func.isRequired,
-}
-
-function RemoveMemberAction({ member }) {
-  const { t } = useTranslation()
-  const { updateProject, monitorRequest } = useShareProjectContext()
-  const { _id: projectId, members, invites } = useProjectContext()
-
-  function handleClick(event) {
-    event.preventDefault()
-
-    monitorRequest(() => removeMemberFromProject(projectId, member)).then(
-      () => {
-        const updatedMembers = members.filter(existing => existing !== member)
-        updateProject({
-          members: updatedMembers,
-        })
-        sendMB('collaborator-removed', {
-          project_id: projectId,
-          current_collaborators_amount: updatedMembers.length,
-          current_invites_amount: invites.length,
-        })
-      }
-    )
-  }
-
-  return (
-    <div className="text-center">
-      <OLTooltip
-        id="remove-collaborator"
-        description={t('remove_collaborator')}
-        overlayProps={{ placement: 'bottom' }}
-      >
-        <OLButton
-          variant="link"
-          onClick={handleClick}
-          className="remove-button"
-          aria-label={t('remove_collaborator')}
-        >
-          <BootstrapVersionSwitcher
-            bs3={<Icon type="times" />}
-            bs5={<MaterialIcon type="clear" />}
-          />
-        </OLButton>
-      </OLTooltip>
-    </div>
-  )
-}
-
-RemoveMemberAction.propTypes = {
-  member: PropTypes.shape({
-    _id: PropTypes.string.isRequired,
-    email: PropTypes.string.isRequired,
-    privileges: PropTypes.string.isRequired,
-  }),
-}
-
-function ChangePrivilegesActions({ handleReset }) {
-  const { t } = useTranslation()
-
-  return (
-    <div className="text-center">
-      <OLButton type="submit" size="sm" variant="primary">
-        {t('change_or_cancel-change')}
-      </OLButton>
-      <div className="text-sm">
-        {t('change_or_cancel-or')}
-        &nbsp;
-        <OLButton
-          variant="link"
-          className="btn-inline-link"
-          onClick={handleReset}
-        >
-          {t('change_or_cancel-cancel')}
-        </OLButton>
-      </div>
-    </div>
-  )
-}
-
-ChangePrivilegesActions.propTypes = {
-  handleReset: PropTypes.func.isRequired,
-}

+ 5 - 3
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/edit-member.tsx → services/web/frontend/js/features/share-project-modal/components/edit-member.tsx

@@ -3,14 +3,14 @@ import PropTypes from 'prop-types'
 import { useTranslation } from 'react-i18next'
 import { useShareProjectContext } from './share-project-modal'
 import TransferOwnershipModal from './transfer-ownership-modal'
-import { removeMemberFromProject, updateMember } from '../../utils/api'
+import { removeMemberFromProject, updateMember } from '../utils/api'
 import Icon from '@/shared/components/icon'
 import { useProjectContext } from '@/shared/context/project-context'
 import { sendMB } from '@/infrastructure/event-tracking'
 import { Select } from '@/shared/components/select'
 import type { ProjectContextMember } from '@/shared/context/types/project-context'
 import { PermissionsLevel } from '@/features/ide-react/types/permissions'
-import { linkSharingEnforcementDate } from '../../utils/link-sharing'
+import { linkSharingEnforcementDate } from '../utils/link-sharing'
 import OLButton from '@/features/ui/components/ol/ol-button'
 import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
 import OLCol from '@/features/ui/components/ol/ol-col'
@@ -128,7 +128,9 @@ export default function EditMember({
       id="share-project-form"
       onSubmit={e => {
         e.preventDefault()
-        commitPrivilegeChange(privileges)
+        if (privilegeChangePending) {
+          commitPrivilegeChange(privileges)
+        }
       }}
     >
       <OLFormGroup

+ 1 - 1
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/editor-over-limit-modal-content.tsx → services/web/frontend/js/features/share-project-modal/components/editor-over-limit-modal-content.tsx

@@ -1,5 +1,5 @@
 import { useTranslation } from 'react-i18next'
-import { linkSharingEnforcementDate } from '../../utils/link-sharing'
+import { linkSharingEnforcementDate } from '../utils/link-sharing'
 import { sendMB } from '@/infrastructure/event-tracking'
 import OLButton from '@/features/ui/components/ol/ol-button'
 import {

+ 0 - 0
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/editor-over-limit-modal.tsx → services/web/frontend/js/features/share-project-modal/components/editor-over-limit-modal.tsx


+ 6 - 7
services/web/frontend/js/features/share-project-modal/components/invite.jsx

@@ -1,12 +1,12 @@
 import { useCallback } from 'react'
 import PropTypes from 'prop-types'
 import { useShareProjectContext } from './share-project-modal'
-import Icon from '../../../shared/components/icon'
+import Icon from '@/shared/components/icon'
 import { useTranslation } from 'react-i18next'
 import MemberPrivileges from './member-privileges'
 import { resendInvite, revokeInvite } from '../utils/api'
-import { useProjectContext } from '../../../shared/context/project-context'
-import { sendMB } from '../../../infrastructure/event-tracking'
+import { useProjectContext } from '@/shared/context/project-context'
+import { sendMB } from '@/infrastructure/event-tracking'
 import OLRow from '@/features/ui/components/ol/ol-row'
 import OLCol from '@/features/ui/components/ol/ol-col'
 import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
@@ -20,9 +20,8 @@ export default function Invite({ invite, isProjectOwner }) {
   const { t } = useTranslation()
   return (
     <OLRow className="project-invite">
-      <OLCol xs={7}>
+      <OLCol xs={8}>
         <div>{invite.email}</div>
-
         <div className="small">
           {t('invite_not_accepted')}
           .&nbsp;
@@ -30,12 +29,12 @@ export default function Invite({ invite, isProjectOwner }) {
         </div>
       </OLCol>
 
-      <OLCol xs={3} className="text-start">
+      <OLCol xs={3} className="text-end">
         <MemberPrivileges privileges={invite.privileges} />
       </OLCol>
 
       {isProjectOwner && (
-        <OLCol xs={2} className="text-center">
+        <OLCol xs={1} className="text-center">
           <RevokeInvite invite={invite} />
         </OLCol>
       )}

+ 55 - 26
services/web/frontend/js/features/share-project-modal/components/link-sharing.jsx

@@ -1,16 +1,16 @@
 import { useCallback, useState, useEffect } from 'react'
 import PropTypes from 'prop-types'
 import { useTranslation } from 'react-i18next'
-import Icon from '../../../shared/components/icon'
+import Icon from '@/shared/components/icon'
 import { useShareProjectContext } from './share-project-modal'
 import { setProjectAccessLevel } from '../utils/api'
 import { CopyToClipboard } from '@/shared/components/copy-to-clipboard'
-import { useProjectContext } from '../../../shared/context/project-context'
+import { useProjectContext } from '@/shared/context/project-context'
 import * as eventTracking from '../../../infrastructure/event-tracking'
-import { useUserContext } from '../../../shared/context/user-context'
+import { useUserContext } from '@/shared/context/user-context'
 import { sendMB } from '../../../infrastructure/event-tracking'
 import { getJSON } from '../../../infrastructure/fetch-json'
-import useAbortController from '../../../shared/hooks/use-abort-controller'
+import useAbortController from '@/shared/hooks/use-abort-controller'
 import { debugConsole } from '@/utils/debugging'
 import getMeta from '@/utils/meta'
 import OLRow from '@/features/ui/components/ol/ol-row'
@@ -22,6 +22,7 @@ import MaterialIcon from '@/shared/components/material-icon'
 
 export default function LinkSharing() {
   const [inflight, setInflight] = useState(false)
+  const [showLinks, setShowLinks] = useState(true)
 
   const { monitorRequest } = useShareProjectContext()
 
@@ -57,6 +58,7 @@ export default function LinkSharing() {
           setAccessLevel={setAccessLevel}
           inflight={inflight}
           projectId={projectId}
+          setShowLinks={setShowLinks}
         />
       )
 
@@ -66,6 +68,8 @@ export default function LinkSharing() {
         <TokenBasedSharing
           setAccessLevel={setAccessLevel}
           inflight={inflight}
+          setShowLinks={setShowLinks}
+          showLinks={showLinks}
         />
       )
 
@@ -85,12 +89,12 @@ export default function LinkSharing() {
   }
 }
 
-function PrivateSharing({ setAccessLevel, inflight, projectId }) {
+function PrivateSharing({ setAccessLevel, inflight, projectId, setShowLinks }) {
   const { t } = useTranslation()
   return (
     <OLRow className="public-access-level">
       <OLCol xs={12} className="text-center">
-        {t('link_sharing_is_off')}
+        <strong>{t('link_sharing_is_off_short')}</strong>
         <span>&nbsp;&nbsp;</span>
         <OLButton
           variant="link"
@@ -98,6 +102,7 @@ function PrivateSharing({ setAccessLevel, inflight, projectId }) {
           onClick={() => {
             setAccessLevel('tokenBased')
             eventTracking.sendMB('link-sharing-click', { projectId })
+            setShowLinks(true)
           }}
           disabled={inflight}
         >
@@ -114,9 +119,15 @@ PrivateSharing.propTypes = {
   setAccessLevel: PropTypes.func.isRequired,
   inflight: PropTypes.bool,
   projectId: PropTypes.string,
+  setShowLinks: PropTypes.func.isRequired,
 }
 
-function TokenBasedSharing({ setAccessLevel, inflight }) {
+function TokenBasedSharing({
+  setAccessLevel,
+  inflight,
+  setShowLinks,
+  showLinks,
+}) {
   const { t } = useTranslation()
   const { _id: projectId } = useProjectContext()
 
@@ -145,27 +156,43 @@ function TokenBasedSharing({ setAccessLevel, inflight }) {
         </OLButton>
         <span>&nbsp;&nbsp;</span>
         <LinkSharingInfo />
-      </OLCol>
-      <OLCol xs={12} className="access-token-display-area">
-        <div className="access-token-wrapper">
-          <strong>{t('anyone_with_link_can_edit')}</strong>
-          <AccessToken
-            token={tokens?.readAndWrite}
-            tokenHashPrefix={tokens?.readAndWriteHashPrefix}
-            path="/"
-            tooltipId="tooltip-copy-link-rw"
-          />
-        </div>
-        <div className="access-token-wrapper">
-          <strong>{t('anyone_with_link_can_view')}</strong>
-          <AccessToken
-            token={tokens?.readOnly}
-            tokenHashPrefix={tokens?.readOnlyHashPrefix}
-            path="/read/"
-            tooltipId="tooltip-copy-link-ro"
+        <OLButton
+          variant="link"
+          className="btn-chevron align-middle"
+          onClick={() => setShowLinks(!showLinks)}
+        >
+          <BootstrapVersionSwitcher
+            bs3={<Icon type={showLinks ? 'chevron-up' : 'chevron-down'} fw />}
+            bs5={
+              <MaterialIcon
+                type={showLinks ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
+              />
+            }
           />
-        </div>
+        </OLButton>
       </OLCol>
+      {showLinks && (
+        <OLCol xs={12} className="access-token-display-area">
+          <div className="access-token-wrapper">
+            <strong>{t('anyone_with_link_can_edit')}</strong>
+            <AccessToken
+              token={tokens?.readAndWrite}
+              tokenHashPrefix={tokens?.readAndWriteHashPrefix}
+              path="/"
+              tooltipId="tooltip-copy-link-rw"
+            />
+          </div>
+          <div className="access-token-wrapper">
+            <strong>{t('anyone_with_link_can_view')}</strong>
+            <AccessToken
+              token={tokens?.readOnly}
+              tokenHashPrefix={tokens?.readOnlyHashPrefix}
+              path="/read/"
+              tooltipId="tooltip-copy-link-ro"
+            />
+          </div>
+        </OLCol>
+      )}
     </OLRow>
   )
 }
@@ -173,6 +200,8 @@ function TokenBasedSharing({ setAccessLevel, inflight }) {
 TokenBasedSharing.propTypes = {
   setAccessLevel: PropTypes.func.isRequired,
   inflight: PropTypes.bool,
+  setShowLinks: PropTypes.func.isRequired,
+  showLinks: PropTypes.bool,
 }
 
 function LegacySharing({ accessLevel, setAccessLevel, inflight }) {

+ 14 - 3
services/web/frontend/js/features/share-project-modal/components/owner-info.jsx

@@ -1,7 +1,10 @@
-import { useProjectContext } from '../../../shared/context/project-context'
+import { useProjectContext } from '@/shared/context/project-context'
 import { useTranslation } from 'react-i18next'
+import Icon from '@/shared/components/icon'
 import OLRow from '@/features/ui/components/ol/ol-row'
 import OLCol from '@/features/ui/components/ol/ol-col'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
 
 export default function OwnerInfo() {
   const { t } = useTranslation()
@@ -9,8 +12,16 @@ export default function OwnerInfo() {
 
   return (
     <OLRow className="project-member">
-      <OLCol xs={7}>{owner?.email}</OLCol>
-      <OLCol xs={3} className="text-start">
+      <OLCol xs={8}>
+        <div className="project-member-email-icon">
+          <BootstrapVersionSwitcher
+            bs3={<Icon type="user" fw />}
+            bs5={<MaterialIcon type="person" />}
+          />
+          <div className="email-warning">{owner?.email}</div>
+        </div>
+      </OLCol>
+      <OLCol xs={4} className="text-end">
         {t('owner')}
       </OLCol>
     </OLRow>

+ 0 - 74
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators-upgrade.tsx

@@ -1,74 +0,0 @@
-import { useTranslation } from 'react-i18next'
-import Notification from '@/shared/components/notification'
-import { upgradePlan } from '@/main/account-upgrade'
-import { linkSharingEnforcementDate } from '../../utils/link-sharing'
-import { useProjectContext } from '@/shared/context/project-context'
-import { useUserContext } from '@/shared/context/user-context'
-import { sendMB } from '@/infrastructure/event-tracking'
-import StartFreeTrialButton from '@/shared/components/start-free-trial-button'
-import OLButton from '@/features/ui/components/ol/ol-button'
-
-export default function AddCollaboratorsUpgrade() {
-  const { t } = useTranslation()
-  const { features } = useProjectContext()
-  const user = useUserContext()
-
-  return (
-    <div className="add-collaborators-upgrade">
-      <Notification
-        isActionBelowContent
-        type="warning"
-        title={t('editor_limit_exceeded_in_this_project')}
-        content={
-          <p>
-            {t('your_plan_is_limited_to_n_editors', {
-              count: features.collaborators,
-            })}{' '}
-            {t('from_enforcement_date', {
-              enforcementDate: linkSharingEnforcementDate,
-            })}
-          </p>
-        }
-        action={
-          <div className="upgrade-actions">
-            {user.allowedFreeTrial ? (
-              <StartFreeTrialButton
-                buttonProps={{ variant: 'secondary', size: 'sm' }}
-                source="project-sharing"
-                variant="exceeds"
-              >
-                {t('upgrade')}
-              </StartFreeTrialButton>
-            ) : (
-              <OLButton
-                variant="secondary"
-                size="sm"
-                onClick={() => {
-                  upgradePlan('project-sharing')
-                }}
-              >
-                {t('upgrade')}
-              </OLButton>
-            )}
-            <OLButton
-              variant="link"
-              size="sm"
-              href="https://www.overleaf.com/blog/changes-to-project-sharing"
-              target="_blank"
-              rel="noreferrer"
-              onClick={() => {
-                sendMB('paywall-info-click', {
-                  'paywall-type': 'project-sharing',
-                  content: 'blog',
-                  variant: 'exceeds',
-                })
-              }}
-            >
-              {t('read_more')}
-            </OLButton>
-          </div>
-        }
-      />
-    </div>
-  )
-}

+ 0 - 197
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators.jsx

@@ -1,197 +0,0 @@
-import { useEffect, useState, useMemo, useCallback } from 'react'
-import { useTranslation } from 'react-i18next'
-import { useMultipleSelection } from 'downshift'
-import { useShareProjectContext } from './share-project-modal'
-import SelectCollaborators from './select-collaborators'
-import { resendInvite, sendInvite } from '../../utils/api'
-import { useUserContacts } from '../../hooks/use-user-contacts'
-import useIsMounted from '@/shared/hooks/use-is-mounted'
-import { useProjectContext } from '@/shared/context/project-context'
-import { sendMB } from '@/infrastructure/event-tracking'
-import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
-import PropTypes from 'prop-types'
-import OLForm from '@/features/ui/components/ol/ol-form'
-import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
-import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
-import OLButton from '@/features/ui/components/ol/ol-button'
-import getMeta from '@/utils/meta'
-
-export default function AddCollaborators({ readOnly }) {
-  const [privileges, setPrivileges] = useState('readAndWrite')
-
-  const isMounted = useIsMounted()
-
-  const { data: contacts } = useUserContacts()
-
-  const { t } = useTranslation()
-
-  const { updateProject, setInFlight, setError } = useShareProjectContext()
-
-  const { _id: projectId, members, invites } = useProjectContext()
-
-  const currentMemberEmails = useMemo(
-    () => (members || []).map(member => member.email).sort(),
-    [members]
-  )
-
-  const nonMemberContacts = useMemo(() => {
-    if (!contacts) {
-      return null
-    }
-
-    return contacts.filter(
-      contact => !currentMemberEmails.includes(contact.email)
-    )
-  }, [contacts, currentMemberEmails])
-
-  const multipleSelectionProps = useMultipleSelection({
-    initialActiveIndex: 0,
-    initialSelectedItems: [],
-  })
-
-  const { reset, selectedItems } = multipleSelectionProps
-
-  useEffect(() => {
-    if (readOnly && privileges === 'readAndWrite') {
-      setPrivileges('readOnly')
-    }
-  }, [privileges, readOnly])
-
-  const handleSubmit = useCallback(async () => {
-    if (!selectedItems.length) {
-      return
-    }
-
-    // reset the selected items
-    reset()
-
-    setError(undefined)
-    setInFlight(true)
-
-    for (const contact of selectedItems) {
-      // unmounting means can't add any more collaborators
-      if (!isMounted.current) {
-        break
-      }
-
-      const email = contact.type === 'user' ? contact.email : contact.display
-      const normalisedEmail = email.toLowerCase()
-
-      if (currentMemberEmails.includes(normalisedEmail)) {
-        continue
-      }
-
-      let data
-
-      try {
-        const invite = (invites || []).find(
-          invite => invite.email === normalisedEmail
-        )
-
-        if (invite) {
-          data = await resendInvite(projectId, invite)
-        } else {
-          data = await sendInvite(projectId, email, privileges)
-        }
-
-        sendMB('collaborator-invited', {
-          project_id: projectId,
-          // invitation is only populated on successful invite, meaning that for paywall and other cases this will be null
-          successful_invite: !!data.invite,
-          users_updated: !!(data.users || data.user),
-          current_collaborators_amount: members.length,
-          current_invites_amount: invites.length,
-        })
-      } catch (error) {
-        setInFlight(false)
-        setError(
-          error.data?.errorReason ||
-            (error.response?.status === 429
-              ? 'too_many_requests'
-              : 'generic_something_went_wrong')
-        )
-        break
-      }
-
-      if (data.error) {
-        setError(data.error)
-        setInFlight(false)
-      } else if (data.invite) {
-        updateProject({
-          invites: invites.concat(data.invite),
-        })
-      } else if (data.users) {
-        updateProject({
-          members: members.concat(data.users),
-        })
-      } else if (data.user) {
-        updateProject({
-          members: members.concat(data.user),
-        })
-      }
-
-      // wait for a short time, so canAddCollaborators has time to update with new collaborator information
-      await new Promise(resolve => setTimeout(resolve, 100))
-    }
-
-    setInFlight(false)
-  }, [
-    currentMemberEmails,
-    invites,
-    isMounted,
-    members,
-    privileges,
-    projectId,
-    reset,
-    selectedItems,
-    setError,
-    setInFlight,
-    updateProject,
-  ])
-
-  return (
-    <OLForm className="add-collabs">
-      <OLFormGroup>
-        <SelectCollaborators
-          loading={!nonMemberContacts}
-          options={nonMemberContacts || []}
-          placeholder="Email, comma separated"
-          multipleSelectionProps={multipleSelectionProps}
-        />
-      </OLFormGroup>
-
-      <OLFormGroup>
-        <div className="pull-right">
-          <OLFormSelect
-            className="privileges"
-            value={privileges}
-            onChange={event => setPrivileges(event.target.value)}
-            bs3Props={{
-              bsSize: 'sm',
-            }}
-          >
-            <option disabled={readOnly} value="readAndWrite">
-              {t('can_edit')}
-            </option>
-            <option value="readOnly">{t('can_view')}</option>
-            {getMeta('ol-isReviewerRoleEnabled') && (
-              <option value="review">{t('can_review')}</option>
-            )}
-          </OLFormSelect>
-          <span>&nbsp;&nbsp;</span>
-          <ClickableElementEnhancer
-            as={OLButton}
-            onClick={handleSubmit}
-            variant="primary"
-          >
-            {t('invite')}
-          </ClickableElementEnhancer>
-        </div>
-      </OLFormGroup>
-    </OLForm>
-  )
-}
-
-AddCollaborators.propTypes = {
-  readOnly: PropTypes.bool,
-}

+ 0 - 142
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/invite.jsx

@@ -1,142 +0,0 @@
-import { useCallback } from 'react'
-import PropTypes from 'prop-types'
-import { useShareProjectContext } from './share-project-modal'
-import Icon from '@/shared/components/icon'
-import { useTranslation } from 'react-i18next'
-import MemberPrivileges from './member-privileges'
-import { resendInvite, revokeInvite } from '../../utils/api'
-import { useProjectContext } from '@/shared/context/project-context'
-import { sendMB } from '@/infrastructure/event-tracking'
-import OLRow from '@/features/ui/components/ol/ol-row'
-import OLCol from '@/features/ui/components/ol/ol-col'
-import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
-import OLButton from '@/features/ui/components/ol/ol-button'
-import MaterialIcon from '@/shared/components/material-icon'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { bsVersion } from '@/features/utils/bootstrap-5'
-import classnames from 'classnames'
-
-export default function Invite({ invite, isProjectOwner }) {
-  const { t } = useTranslation()
-  return (
-    <OLRow className="project-invite">
-      <OLCol xs={8}>
-        <div>{invite.email}</div>
-        <div className="small">
-          {t('invite_not_accepted')}
-          .&nbsp;
-          {isProjectOwner && <ResendInvite invite={invite} />}
-        </div>
-      </OLCol>
-
-      <OLCol xs={3} className="text-end">
-        <MemberPrivileges privileges={invite.privileges} />
-      </OLCol>
-
-      {isProjectOwner && (
-        <OLCol xs={1} className="text-center">
-          <RevokeInvite invite={invite} />
-        </OLCol>
-      )}
-    </OLRow>
-  )
-}
-
-Invite.propTypes = {
-  invite: PropTypes.object.isRequired,
-  isProjectOwner: PropTypes.bool.isRequired,
-}
-
-function ResendInvite({ invite }) {
-  const { t } = useTranslation()
-  const { monitorRequest, setError, inFlight } = useShareProjectContext()
-  const { _id: projectId } = useProjectContext()
-
-  // const buttonRef = useRef(null)
-  //
-  const handleClick = useCallback(
-    () =>
-      monitorRequest(() => resendInvite(projectId, invite))
-        .catch(error => {
-          if (error?.response?.status === 404) {
-            setError('invite_expired')
-          }
-          if (error?.response?.status === 429) {
-            setError('invite_resend_limit_hit')
-          }
-        })
-        .finally(() => {
-          // NOTE: disabled as react-bootstrap v0.33.1 isn't forwarding the ref to the `button`
-          // if (buttonRef.current) {
-          //   buttonRef.current.blur()
-          // }
-          document.activeElement.blur()
-        }),
-    [invite, monitorRequest, projectId, setError]
-  )
-
-  return (
-    <OLButton
-      variant="link"
-      className="btn-inline-link"
-      onClick={handleClick}
-      disabled={inFlight}
-      // ref={buttonRef}
-    >
-      {t('resend')}
-    </OLButton>
-  )
-}
-
-ResendInvite.propTypes = {
-  invite: PropTypes.object.isRequired,
-}
-
-function RevokeInvite({ invite }) {
-  const { t } = useTranslation()
-  const { updateProject, monitorRequest } = useShareProjectContext()
-  const { _id: projectId, invites, members } = useProjectContext()
-
-  function handleClick(event) {
-    event.preventDefault()
-
-    monitorRequest(() => revokeInvite(projectId, invite)).then(() => {
-      const updatedInvites = invites.filter(existing => existing !== invite)
-      updateProject({
-        invites: updatedInvites,
-      })
-      sendMB('collaborator-invite-revoked', {
-        project_id: projectId,
-        current_invites_amount: updatedInvites.length,
-        current_collaborators_amount: members.length,
-      })
-    })
-  }
-
-  return (
-    <OLTooltip
-      id="revoke-invite"
-      description={t('revoke_invite')}
-      overlayProps={{ placement: 'bottom' }}
-    >
-      <OLButton
-        variant="link"
-        onClick={handleClick}
-        aria-label={t('revoke')}
-        className={classnames(
-          'btn-inline-link',
-          bsVersion({ bs5: 'text-decoration-none' })
-        )}
-      >
-        <BootstrapVersionSwitcher
-          bs3={<Icon type="times" />}
-          bs5={<MaterialIcon type="clear" />}
-        />
-      </OLButton>
-    </OLTooltip>
-  )
-}
-
-RevokeInvite.propTypes = {
-  invite: PropTypes.object.isRequired,
-}

+ 0 - 325
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/link-sharing.jsx

@@ -1,325 +0,0 @@
-import { useCallback, useState, useEffect } from 'react'
-import PropTypes from 'prop-types'
-import { useTranslation } from 'react-i18next'
-import Icon from '@/shared/components/icon'
-import { useShareProjectContext } from './share-project-modal'
-import { setProjectAccessLevel } from '../../utils/api'
-import { CopyToClipboard } from '@/shared/components/copy-to-clipboard'
-import { useProjectContext } from '@/shared/context/project-context'
-import * as eventTracking from '../../../../infrastructure/event-tracking'
-import { useUserContext } from '@/shared/context/user-context'
-import { sendMB } from '../../../../infrastructure/event-tracking'
-import { getJSON } from '../../../../infrastructure/fetch-json'
-import useAbortController from '@/shared/hooks/use-abort-controller'
-import { debugConsole } from '@/utils/debugging'
-import getMeta from '@/utils/meta'
-import OLRow from '@/features/ui/components/ol/ol-row'
-import OLCol from '@/features/ui/components/ol/ol-col'
-import OLButton from '@/features/ui/components/ol/ol-button'
-import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import MaterialIcon from '@/shared/components/material-icon'
-
-export default function LinkSharing() {
-  const [inflight, setInflight] = useState(false)
-  const [showLinks, setShowLinks] = useState(true)
-
-  const { monitorRequest } = useShareProjectContext()
-
-  const { _id: projectId, publicAccessLevel } = useProjectContext()
-
-  // set the access level of a project
-  const setAccessLevel = useCallback(
-    newPublicAccessLevel => {
-      setInflight(true)
-      sendMB('link-sharing-click-off', {
-        project_id: projectId,
-      })
-      monitorRequest(() =>
-        setProjectAccessLevel(projectId, newPublicAccessLevel)
-      )
-        .then(() => {
-          // NOTE: not calling `updateProject` here as it receives data via
-          // project:publicAccessLevel:changed over the websocket connection
-          // TODO: eventTracking.sendMB('project-make-token-based') when newPublicAccessLevel is 'tokenBased'
-        })
-        .finally(() => {
-          setInflight(false)
-        })
-    },
-    [monitorRequest, projectId]
-  )
-
-  switch (publicAccessLevel) {
-    // Private (with token-access available)
-    case 'private':
-      return (
-        <PrivateSharing
-          setAccessLevel={setAccessLevel}
-          inflight={inflight}
-          projectId={projectId}
-          setShowLinks={setShowLinks}
-        />
-      )
-
-    // Token-based access
-    case 'tokenBased':
-      return (
-        <TokenBasedSharing
-          setAccessLevel={setAccessLevel}
-          inflight={inflight}
-          setShowLinks={setShowLinks}
-          showLinks={showLinks}
-        />
-      )
-
-    // Legacy public-access
-    case 'readAndWrite':
-    case 'readOnly':
-      return (
-        <LegacySharing
-          setAccessLevel={setAccessLevel}
-          accessLevel={publicAccessLevel}
-          inflight={inflight}
-        />
-      )
-
-    default:
-      return null
-  }
-}
-
-function PrivateSharing({ setAccessLevel, inflight, projectId, setShowLinks }) {
-  const { t } = useTranslation()
-  return (
-    <OLRow className="public-access-level">
-      <OLCol xs={12} className="text-center">
-        <strong>{t('link_sharing_is_off_short')}</strong>
-        <span>&nbsp;&nbsp;</span>
-        <OLButton
-          variant="link"
-          className="btn-inline-link"
-          onClick={() => {
-            setAccessLevel('tokenBased')
-            eventTracking.sendMB('link-sharing-click', { projectId })
-            setShowLinks(true)
-          }}
-          disabled={inflight}
-        >
-          {t('turn_on_link_sharing')}
-        </OLButton>
-        <span>&nbsp;&nbsp;</span>
-        <LinkSharingInfo />
-      </OLCol>
-    </OLRow>
-  )
-}
-
-PrivateSharing.propTypes = {
-  setAccessLevel: PropTypes.func.isRequired,
-  inflight: PropTypes.bool,
-  projectId: PropTypes.string,
-  setShowLinks: PropTypes.func.isRequired,
-}
-
-function TokenBasedSharing({
-  setAccessLevel,
-  inflight,
-  setShowLinks,
-  showLinks,
-}) {
-  const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
-
-  const [tokens, setTokens] = useState(null)
-
-  const { signal } = useAbortController()
-
-  useEffect(() => {
-    getJSON(`/project/${projectId}/tokens`, { signal })
-      .then(data => setTokens(data))
-      .catch(debugConsole.error)
-  }, [projectId, signal])
-
-  return (
-    <OLRow className="public-access-level">
-      <OLCol xs={12} className="text-center">
-        <strong>{t('link_sharing_is_on')}</strong>
-        <span>&nbsp;&nbsp;</span>
-        <OLButton
-          variant="link"
-          className="btn-inline-link"
-          onClick={() => setAccessLevel('private')}
-          disabled={inflight}
-        >
-          {t('turn_off_link_sharing')}
-        </OLButton>
-        <span>&nbsp;&nbsp;</span>
-        <LinkSharingInfo />
-        <OLButton
-          variant="link"
-          className="btn-chevron align-middle"
-          onClick={() => setShowLinks(!showLinks)}
-        >
-          <BootstrapVersionSwitcher
-            bs3={<Icon type={showLinks ? 'chevron-up' : 'chevron-down'} fw />}
-            bs5={
-              <MaterialIcon
-                type={showLinks ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
-              />
-            }
-          />
-        </OLButton>
-      </OLCol>
-      {showLinks && (
-        <OLCol xs={12} className="access-token-display-area">
-          <div className="access-token-wrapper">
-            <strong>{t('anyone_with_link_can_edit')}</strong>
-            <AccessToken
-              token={tokens?.readAndWrite}
-              tokenHashPrefix={tokens?.readAndWriteHashPrefix}
-              path="/"
-              tooltipId="tooltip-copy-link-rw"
-            />
-          </div>
-          <div className="access-token-wrapper">
-            <strong>{t('anyone_with_link_can_view')}</strong>
-            <AccessToken
-              token={tokens?.readOnly}
-              tokenHashPrefix={tokens?.readOnlyHashPrefix}
-              path="/read/"
-              tooltipId="tooltip-copy-link-ro"
-            />
-          </div>
-        </OLCol>
-      )}
-    </OLRow>
-  )
-}
-
-TokenBasedSharing.propTypes = {
-  setAccessLevel: PropTypes.func.isRequired,
-  inflight: PropTypes.bool,
-  setShowLinks: PropTypes.func.isRequired,
-  showLinks: PropTypes.bool,
-}
-
-function LegacySharing({ accessLevel, setAccessLevel, inflight }) {
-  const { t } = useTranslation()
-
-  return (
-    <OLRow className="public-access-level">
-      <OLCol xs={12} className="text-center">
-        <strong>
-          {accessLevel === 'readAndWrite' && t('this_project_is_public')}
-          {accessLevel === 'readOnly' && t('this_project_is_public_read_only')}
-        </strong>
-        <span>&nbsp;&nbsp;</span>
-        <OLButton
-          variant="link"
-          className="btn-inline-link"
-          onClick={() => setAccessLevel('private')}
-          disabled={inflight}
-        >
-          {t('make_private')}
-        </OLButton>
-        <span>&nbsp;&nbsp;</span>
-        <LinkSharingInfo />
-      </OLCol>
-    </OLRow>
-  )
-}
-
-LegacySharing.propTypes = {
-  accessLevel: PropTypes.string.isRequired,
-  setAccessLevel: PropTypes.func.isRequired,
-  inflight: PropTypes.bool,
-}
-
-export function ReadOnlyTokenLink() {
-  const { t } = useTranslation()
-  const { _id: projectId } = useProjectContext()
-
-  const [tokens, setTokens] = useState(null)
-
-  const { signal } = useAbortController()
-
-  useEffect(() => {
-    getJSON(`/project/${projectId}/tokens`, { signal })
-      .then(data => setTokens(data))
-      .catch(debugConsole.error)
-  }, [projectId, signal])
-
-  return (
-    <OLRow className="public-access-level">
-      <OLCol className="access-token-display-area">
-        <div className="access-token-wrapper">
-          <strong>{t('anyone_with_link_can_view')}</strong>
-          <AccessToken
-            token={tokens?.readOnly}
-            tokenHashPrefix={tokens?.readOnlyHashPrefix}
-            path="/read/"
-            tooltipId="tooltip-copy-link-ro"
-          />
-        </div>
-      </OLCol>
-    </OLRow>
-  )
-}
-
-function AccessToken({ token, tokenHashPrefix, path, tooltipId }) {
-  const { t } = useTranslation()
-  const { isAdmin } = useUserContext()
-
-  if (!token) {
-    return (
-      <pre className="access-token">
-        <span>{t('loading')}…</span>
-      </pre>
-    )
-  }
-
-  let origin = window.location.origin
-  if (isAdmin) {
-    origin = getMeta('ol-ExposedSettings').siteUrl
-  }
-  const link = `${origin}${path}${token}${
-    tokenHashPrefix ? `#${tokenHashPrefix}` : ''
-  }`
-
-  return (
-    <div className="access-token">
-      <code>{link}</code>
-      <CopyToClipboard content={link} tooltipId={tooltipId} />
-    </div>
-  )
-}
-
-AccessToken.propTypes = {
-  token: PropTypes.string,
-  tokenHashPrefix: PropTypes.string,
-  tooltipId: PropTypes.string.isRequired,
-  path: PropTypes.string.isRequired,
-}
-
-function LinkSharingInfo() {
-  const { t } = useTranslation()
-
-  return (
-    <OLTooltip
-      id="link-sharing-info"
-      description={t('learn_more_about_link_sharing')}
-    >
-      <a
-        href="/learn/how-to/What_is_Link_Sharing%3F"
-        target="_blank"
-        rel="noopener"
-      >
-        <BootstrapVersionSwitcher
-          bs3={<Icon type="question-circle" />}
-          bs5={<MaterialIcon type="help" className="align-middle" />}
-        />
-      </a>
-    </OLTooltip>
-  )
-}

+ 0 - 23
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/member-privileges.jsx

@@ -1,23 +0,0 @@
-import PropTypes from 'prop-types'
-import { useTranslation } from 'react-i18next'
-
-export default function MemberPrivileges({ privileges }) {
-  const { t } = useTranslation()
-
-  switch (privileges) {
-    case 'readAndWrite':
-      return t('can_edit')
-
-    case 'readOnly':
-      return t('read_only')
-
-    case 'review':
-      return t('can_review')
-
-    default:
-      return null
-  }
-}
-MemberPrivileges.propTypes = {
-  privileges: PropTypes.string.isRequired,
-}

+ 0 - 29
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/owner-info.jsx

@@ -1,29 +0,0 @@
-import { useProjectContext } from '@/shared/context/project-context'
-import { useTranslation } from 'react-i18next'
-import Icon from '@/shared/components/icon'
-import OLRow from '@/features/ui/components/ol/ol-row'
-import OLCol from '@/features/ui/components/ol/ol-col'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import MaterialIcon from '@/shared/components/material-icon'
-
-export default function OwnerInfo() {
-  const { t } = useTranslation()
-  const { owner } = useProjectContext()
-
-  return (
-    <OLRow className="project-member">
-      <OLCol xs={8}>
-        <div className="project-member-email-icon">
-          <BootstrapVersionSwitcher
-            bs3={<Icon type="user" fw />}
-            bs5={<MaterialIcon type="person" />}
-          />
-          <div className="email-warning">{owner?.email}</div>
-        </div>
-      </OLCol>
-      <OLCol xs={4} className="text-end">
-        {t('owner')}
-      </OLCol>
-    </OLRow>
-  )
-}

+ 0 - 419
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/select-collaborators.jsx

@@ -1,419 +0,0 @@
-import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
-import PropTypes from 'prop-types'
-import { useTranslation } from 'react-i18next'
-import { matchSorter } from 'match-sorter'
-import { useCombobox } from 'downshift'
-import classnames from 'classnames'
-
-import Icon from '@/shared/components/icon'
-import MaterialIcon from '@/shared/components/material-icon'
-import Tag from '@/features/ui/components/bootstrap-5/tag'
-import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { bsVersion } from '@/features/utils/bootstrap-5'
-import { Spinner } from 'react-bootstrap-5'
-
-// Unicode characters in these Unicode groups:
-//  "General Punctuation — Spaces"
-//  "General Punctuation — Format character" (including zero-width spaces)
-const matchAllSpaces =
-  /[\u061C\u2000-\u200F\u202A-\u202E\u2060\u2066-\u2069\u2028\u2029\u202F]/g
-
-export default function SelectCollaborators({
-  loading,
-  options,
-  placeholder,
-  multipleSelectionProps,
-}) {
-  const { t } = useTranslation()
-  const {
-    getSelectedItemProps,
-    getDropdownProps,
-    addSelectedItem,
-    removeSelectedItem,
-    selectedItems,
-  } = multipleSelectionProps
-
-  const [inputValue, setInputValue] = useState('')
-
-  const selectedEmails = useMemo(
-    () => selectedItems.map(item => item.email),
-    [selectedItems]
-  )
-
-  const unselectedOptions = useMemo(
-    () => options.filter(option => !selectedEmails.includes(option.email)),
-    [options, selectedEmails]
-  )
-
-  const filteredOptions = useMemo(() => {
-    if (inputValue === '') {
-      return unselectedOptions
-    }
-
-    return matchSorter(unselectedOptions, inputValue, {
-      keys: ['name', 'email'],
-      threshold: matchSorter.rankings.CONTAINS,
-      baseSort: (a, b) => {
-        // Prefer server-side sorting for ties in the match ranking.
-        return a.index - b.index > 0 ? 1 : -1
-      },
-    })
-  }, [unselectedOptions, inputValue])
-
-  const inputRef = useRef(null)
-
-  const focusInput = useCallback(() => {
-    if (inputRef.current) {
-      window.setTimeout(() => {
-        inputRef.current.focus()
-      }, 10)
-    }
-  }, [inputRef])
-
-  const isValidInput = useMemo(() => {
-    if (inputValue.includes('@')) {
-      for (const selectedItem of selectedItems) {
-        if (selectedItem.email === inputValue) {
-          return false
-        }
-      }
-    }
-
-    return true
-  }, [inputValue, selectedItems])
-
-  function stateReducer(state, actionAndChanges) {
-    const { type, changes } = actionAndChanges
-    // force selected item to be null so that adding, removing, then re-adding the same collaborator is recognised as a selection change
-    if (type === useCombobox.stateChangeTypes.InputChange) {
-      return { ...changes, selectedItem: null }
-    }
-    return changes
-  }
-
-  const {
-    isOpen,
-    getLabelProps,
-    getMenuProps,
-    getInputProps,
-    getComboboxProps,
-    highlightedIndex,
-    getItemProps,
-    reset,
-  } = useCombobox({
-    inputValue,
-    defaultHighlightedIndex: 0,
-    items: filteredOptions,
-    itemToString: item => item && item.name,
-    stateReducer,
-    onStateChange: ({ inputValue, type, selectedItem }) => {
-      switch (type) {
-        // add a selected item on Enter (keypress), click or blur
-        case useCombobox.stateChangeTypes.InputKeyDownEnter:
-        case useCombobox.stateChangeTypes.ItemClick:
-        case useCombobox.stateChangeTypes.InputBlur:
-          if (selectedItem) {
-            setInputValue('')
-            addSelectedItem(selectedItem)
-          }
-          break
-      }
-    },
-  })
-
-  const addNewItem = useCallback(
-    (_email, focus = true) => {
-      const email = _email.replace(matchAllSpaces, '')
-
-      if (
-        isValidInput &&
-        email.includes('@') &&
-        !selectedEmails.includes(email)
-      ) {
-        addSelectedItem({
-          email,
-          display: email,
-          type: 'user',
-        })
-        setInputValue('')
-        reset()
-        if (focus) {
-          focusInput()
-        }
-        return true
-      }
-    },
-    [addSelectedItem, selectedEmails, isValidInput, focusInput, reset]
-  )
-
-  // close and reset the menu when there are no matching items
-  useEffect(() => {
-    if (isOpen && filteredOptions.length === 0) {
-      reset()
-    }
-  }, [reset, isOpen, filteredOptions.length])
-
-  return (
-    <div className="tags-input tags-new">
-      {/* eslint-disable-next-line jsx-a11y/label-has-for */}
-      <label className="small" {...getLabelProps()}>
-        <strong>
-          {t('add_people')}
-          &nbsp;
-        </strong>
-        {loading && (
-          <BootstrapVersionSwitcher
-            bs3={<Icon type="refresh" spin />}
-            bs5={
-              <Spinner
-                animation="border"
-                aria-hidden="true"
-                size="sm"
-                role="status"
-              />
-            }
-          />
-        )}
-      </label>
-
-      <div className="host">
-        {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
-        <div
-          {...getComboboxProps()}
-          className="tags form-control"
-          onClick={focusInput}
-        >
-          {selectedItems.map((selectedItem, index) => (
-            <SelectedItem
-              key={`selected-item-${index}`}
-              removeSelectedItem={removeSelectedItem}
-              selectedItem={selectedItem}
-              focusInput={focusInput}
-              index={index}
-              getSelectedItemProps={getSelectedItemProps}
-            />
-          ))}
-
-          <input
-            {...getInputProps(
-              getDropdownProps({
-                className: classnames('input', {
-                  'invalid-tag': !isValidInput,
-                }),
-                type: 'email',
-                placeholder,
-                size: inputValue.length
-                  ? inputValue.length + 5
-                  : placeholder.length,
-                ref: inputRef,
-                // preventKeyAction: showDropdown,
-                onBlur: () => {
-                  addNewItem(inputValue, false)
-                },
-                onChange: e => {
-                  setInputValue(e.target.value)
-                },
-                onClick: () => focusInput,
-                onKeyDown: event => {
-                  switch (event.key) {
-                    case 'Enter':
-                      // Enter: always prevent form submission
-                      event.preventDefault()
-                      event.stopPropagation()
-                      break
-
-                    case 'Tab':
-                      // Tab: if the dropdown isn't open, try to create a new item using inputValue and prevent blur if successful
-                      if (!isOpen && addNewItem(inputValue)) {
-                        event.preventDefault()
-                        event.stopPropagation()
-                      }
-                      break
-
-                    case ',':
-                      // comma: try to create a new item using inputValue
-                      event.preventDefault()
-                      addNewItem(inputValue)
-                      break
-                  }
-                },
-                onPaste: event => {
-                  const data =
-                    // modern browsers
-                    event.clipboardData?.getData('text/plain') ??
-                    // IE11
-                    window.clipboardData?.getData('text')
-
-                  if (data) {
-                    const emails = data
-                      .split(/[\r\n,; ]+/)
-                      .filter(item => item.includes('@'))
-
-                    if (emails.length) {
-                      // pasted comma-separated email addresses
-                      event.preventDefault()
-
-                      for (const email of emails) {
-                        addNewItem(email)
-                      }
-                    }
-                  }
-                },
-              })
-            )}
-          />
-        </div>
-
-        <div
-          className={bsVersion({ bs3: classnames({ autocomplete: isOpen }) })}
-        >
-          <ul
-            {...getMenuProps()}
-            className={classnames(
-              bsVersion({
-                bs3: 'suggestion-list',
-                bs5: classnames('dropdown-menu select-dropdown-menu', {
-                  show: isOpen,
-                }),
-              })
-            )}
-          >
-            {isOpen &&
-              filteredOptions.map((item, index) => (
-                <Option
-                  key={item.email}
-                  index={index}
-                  item={item}
-                  selected={index === highlightedIndex}
-                  getItemProps={getItemProps}
-                />
-              ))}
-          </ul>
-        </div>
-      </div>
-    </div>
-  )
-}
-SelectCollaborators.propTypes = {
-  loading: PropTypes.bool.isRequired,
-  options: PropTypes.array.isRequired,
-  placeholder: PropTypes.string,
-  multipleSelectionProps: PropTypes.shape({
-    getSelectedItemProps: PropTypes.func.isRequired,
-    getDropdownProps: PropTypes.func.isRequired,
-    addSelectedItem: PropTypes.func.isRequired,
-    removeSelectedItem: PropTypes.func.isRequired,
-    selectedItems: PropTypes.array.isRequired,
-  }).isRequired,
-}
-
-function Option({ selected, item, getItemProps, index }) {
-  return (
-    <li
-      className={bsVersion({
-        bs3: classnames('suggestion-item', { selected }),
-      })}
-      {...getItemProps({ item, index })}
-    >
-      <BootstrapVersionSwitcher
-        bs3={
-          <>
-            <Icon type="user" fw />
-            &nbsp;
-            {item.display}
-          </>
-        }
-        bs5={
-          <DropdownItem
-            as="span"
-            role={undefined}
-            leadingIcon="person"
-            className={classnames({
-              active: selected,
-            })}
-          >
-            {item.display}
-          </DropdownItem>
-        }
-      />
-    </li>
-  )
-}
-
-Option.propTypes = {
-  selected: PropTypes.bool.isRequired,
-  item: PropTypes.shape({
-    display: PropTypes.string.isRequired,
-  }),
-  index: PropTypes.number.isRequired,
-  getItemProps: PropTypes.func.isRequired,
-}
-
-function SelectedItem({
-  removeSelectedItem,
-  selectedItem,
-  focusInput,
-  getSelectedItemProps,
-  index,
-}) {
-  const { t } = useTranslation()
-
-  const handleClick = useCallback(
-    event => {
-      event.preventDefault()
-      event.stopPropagation()
-      removeSelectedItem(selectedItem)
-      focusInput()
-    },
-    [focusInput, removeSelectedItem, selectedItem]
-  )
-
-  return (
-    <BootstrapVersionSwitcher
-      bs3={
-        <span
-          className="tag-item"
-          {...getSelectedItemProps({ selectedItem, index })}
-        >
-          <Icon type="user" fw />
-          <span>{selectedItem.display}</span>
-          <button
-            type="button"
-            className="remove-button btn-inline-link"
-            aria-label={t('remove')}
-            onClick={handleClick}
-          >
-            <Icon type="close" fw />
-          </button>
-        </span>
-      }
-      bs5={
-        <Tag
-          prepend={
-            <BootstrapVersionSwitcher
-              bs3={<Icon type="user" fw />}
-              bs5={<MaterialIcon type="person" />}
-            />
-          }
-          closeBtnProps={{
-            onClick: handleClick,
-          }}
-          {...getSelectedItemProps({ selectedItem, index })}
-        >
-          {selectedItem.display}
-        </Tag>
-      }
-    />
-  )
-}
-
-SelectedItem.propTypes = {
-  focusInput: PropTypes.func.isRequired,
-  removeSelectedItem: PropTypes.func.isRequired,
-  selectedItem: PropTypes.shape({
-    display: PropTypes.string.isRequired,
-  }),
-  getSelectedItemProps: PropTypes.func.isRequired,
-  index: PropTypes.number.isRequired,
-}

+ 0 - 39
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites.jsx

@@ -1,39 +0,0 @@
-import AddCollaborators from './add-collaborators'
-import AddCollaboratorsUpgrade from './add-collaborators-upgrade'
-import CollaboratorsLimitUpgrade from './collaborators-limit-upgrade'
-import AccessLevelsChanged from './access-levels-changed'
-import PropTypes from 'prop-types'
-import OLRow from '@/features/ui/components/ol/ol-row'
-
-export default function SendInvites({
-  canAddCollaborators,
-  hasExceededCollaboratorLimit,
-  haveAnyEditorsBeenDowngraded,
-  somePendingEditorsResolved,
-}) {
-  return (
-    <OLRow className="invite-controls">
-      {hasExceededCollaboratorLimit && !haveAnyEditorsBeenDowngraded && (
-        <AddCollaboratorsUpgrade />
-      )}
-
-      {haveAnyEditorsBeenDowngraded && (
-        <AccessLevelsChanged
-          somePendingEditorsResolved={somePendingEditorsResolved}
-        />
-      )}
-
-      {!canAddCollaborators &&
-        !hasExceededCollaboratorLimit &&
-        !haveAnyEditorsBeenDowngraded && <CollaboratorsLimitUpgrade />}
-      <AddCollaborators readOnly={!canAddCollaborators} />
-    </OLRow>
-  )
-}
-
-SendInvites.propTypes = {
-  canAddCollaborators: PropTypes.bool,
-  hasExceededCollaboratorLimit: PropTypes.bool,
-  haveAnyEditorsBeenDowngraded: PropTypes.bool,
-  somePendingEditorsResolved: PropTypes.bool,
-}

+ 0 - 128
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-modal-body.tsx

@@ -1,128 +0,0 @@
-import EditMember from './edit-member'
-import LinkSharing from './link-sharing'
-import Invite from './invite'
-import SendInvites from './send-invites'
-import ViewMember from './view-member'
-import OwnerInfo from './owner-info'
-import SendInvitesNotice from './send-invites-notice'
-import { useEditorContext } from '@/shared/context/editor-context'
-import { useProjectContext } from '@/shared/context/project-context'
-import { useMemo } from 'react'
-import RecaptchaConditions from '@/shared/components/recaptcha-conditions'
-import getMeta from '@/utils/meta'
-
-export default function ShareModalBody() {
-  const { members, invites, features } = useProjectContext()
-  const { isProjectOwner } = useEditorContext()
-
-  // whether the project has not reached the collaborator limit
-  const canAddCollaborators = useMemo(() => {
-    if (!isProjectOwner || !features) {
-      return false
-    }
-
-    if (features.collaborators === -1) {
-      // infinite collaborators
-      return true
-    }
-
-    const editorInvites = invites.filter(
-      invite => invite.privileges === 'readAndWrite'
-    ).length
-
-    return (
-      members.filter(member => member.privileges === 'readAndWrite').length +
-        editorInvites <
-      (features.collaborators ?? 1)
-    )
-  }, [members, invites, features, isProjectOwner])
-
-  // determine if some but not all pending editors' permissions have been resolved,
-  // for moving between warning and info notification states etc.
-  const somePendingEditorsResolved = useMemo(() => {
-    return (
-      members.some(member => member.privileges === 'readAndWrite') &&
-      members.some(member => member.pendingEditor)
-    )
-  }, [members])
-
-  const haveAnyEditorsBeenDowngraded = useMemo(() => {
-    if (!isProjectOwner || !features) {
-      return false
-    }
-
-    if (features.collaborators === -1) {
-      return false
-    }
-    return members.some(member => member.pendingEditor)
-  }, [features, isProjectOwner, members])
-
-  const hasExceededCollaboratorLimit = useMemo(() => {
-    if (!isProjectOwner || !features) {
-      return false
-    }
-
-    if (features.collaborators === -1) {
-      return false
-    }
-
-    return (
-      members.filter(member => member.privileges === 'readAndWrite').length >
-      (features.collaborators ?? 1)
-    )
-  }, [features, isProjectOwner, members])
-
-  const sortedMembers = useMemo(() => {
-    return [
-      ...members.filter(member => member.privileges === 'readAndWrite'),
-      ...members.filter(member => member.pendingEditor),
-      ...members.filter(
-        member => !member.pendingEditor && member.privileges !== 'readAndWrite'
-      ),
-    ]
-  }, [members])
-
-  return (
-    <>
-      {isProjectOwner ? (
-        <SendInvites
-          canAddCollaborators={canAddCollaborators}
-          hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
-          haveAnyEditorsBeenDowngraded={haveAnyEditorsBeenDowngraded}
-          somePendingEditorsResolved={somePendingEditorsResolved}
-        />
-      ) : (
-        <SendInvitesNotice />
-      )}
-      {isProjectOwner && <LinkSharing />}
-
-      <OwnerInfo />
-
-      {sortedMembers.map(member =>
-        isProjectOwner ? (
-          <EditMember
-            key={member._id}
-            member={member}
-            hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
-            hasBeenDowngraded={member.pendingEditor ?? false}
-            canAddCollaborators={canAddCollaborators}
-          />
-        ) : (
-          <ViewMember key={member._id} member={member} />
-        )
-      )}
-
-      {invites.map(invite => (
-        <Invite
-          key={invite._id}
-          invite={invite}
-          isProjectOwner={isProjectOwner}
-        />
-      ))}
-
-      {!getMeta('ol-ExposedSettings').recaptchaDisabled?.invite && (
-        <RecaptchaConditions />
-      )}
-    </>
-  )
-}

+ 0 - 129
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal-content.tsx

@@ -1,129 +0,0 @@
-import { useTranslation } from 'react-i18next'
-import Icon from '@/shared/components/icon'
-import { useEditorContext } from '@/shared/context/editor-context'
-import { lazy, Suspense } from 'react'
-import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
-import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
-import OLModal, {
-  OLModalBody,
-  OLModalFooter,
-  OLModalHeader,
-  OLModalTitle,
-} from '@/features/ui/components/ol/ol-modal'
-import OLNotification from '@/features/ui/components/ol/ol-notification'
-import OLButton from '@/features/ui/components/ol/ol-button'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { bsVersion } from '@/features/utils/bootstrap-5'
-import { Spinner } from 'react-bootstrap-5'
-
-const ReadOnlyTokenLink = lazy(() =>
-  import('./link-sharing').then(({ ReadOnlyTokenLink }) => ({
-    // re-export as default -- lazy can only handle default exports.
-    default: ReadOnlyTokenLink,
-  }))
-)
-
-const ShareModalBody = lazy(() => import('./share-modal-body'))
-
-type ShareProjectModalContentProps = {
-  cancel: () => void
-  show: boolean
-  animation: boolean
-  inFlight: boolean
-  error: string | undefined
-}
-
-export default function ShareProjectModalContent({
-  show,
-  cancel,
-  animation,
-  inFlight,
-  error,
-}: ShareProjectModalContentProps) {
-  const { t } = useTranslation()
-
-  const { isRestrictedTokenMember } = useEditorContext()
-
-  return (
-    <OLModal show={show} onHide={cancel} animation={animation}>
-      <OLModalHeader closeButton>
-        <OLModalTitle>{t('share_project')}</OLModalTitle>
-      </OLModalHeader>
-
-      <OLModalBody className="modal-body-share modal-link-share-new">
-        <div className="container-fluid">
-          <Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
-            {isRestrictedTokenMember ? (
-              <ReadOnlyTokenLink />
-            ) : (
-              <ShareModalBody />
-            )}
-          </Suspense>
-          {error && (
-            <OLNotification
-              type="error"
-              content={<ErrorMessage error={error} />}
-              className="mb-0 mt-3"
-            />
-          )}
-        </div>
-      </OLModalBody>
-
-      <OLModalFooter>
-        <div className={bsVersion({ bs3: 'pull-left', bs5: 'me-auto' })}>
-          {inFlight && (
-            <BootstrapVersionSwitcher
-              bs3={<Icon type="refresh" spin />}
-              bs5={
-                <Spinner
-                  animation="border"
-                  aria-hidden="true"
-                  size="sm"
-                  role="status"
-                />
-              }
-            />
-          )}
-        </div>
-
-        <ClickableElementEnhancer
-          onClick={cancel}
-          as={OLButton}
-          variant="secondary"
-          disabled={inFlight}
-        >
-          {t('close')}
-        </ClickableElementEnhancer>
-      </OLModalFooter>
-    </OLModal>
-  )
-}
-
-function ErrorMessage({ error }: Pick<ShareProjectModalContentProps, 'error'>) {
-  const { t } = useTranslation()
-  switch (error) {
-    case 'cannot_invite_non_user':
-      return <>{t('cannot_invite_non_user')}</>
-
-    case 'cannot_verify_user_not_robot':
-      return <>{t('cannot_verify_user_not_robot')}</>
-
-    case 'cannot_invite_self':
-      return <>{t('cannot_invite_self')}</>
-
-    case 'invalid_email':
-      return <>{t('invalid_email')}</>
-
-    case 'too_many_requests':
-      return <>{t('too_many_requests')}</>
-
-    case 'invite_expired':
-      return <>{t('invite_expired')}</>
-
-    case 'invite_resend_limit_hit':
-      return <>{t('invite_resend_limit_hit')}</>
-
-    default:
-      return <>{t('generic_something_went_wrong')}</>
-  }
-}

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

@@ -1,179 +0,0 @@
-import React, {
-  createContext,
-  useCallback,
-  useContext,
-  useEffect,
-  useState,
-} from 'react'
-import ShareProjectModalContent from './share-project-modal-content'
-import { useProjectContext } from '@/shared/context/project-context'
-import { useSplitTestContext } from '@/shared/context/split-test-context'
-import { sendMB } from '@/infrastructure/event-tracking'
-import { ProjectContextUpdateValue } from '@/shared/context/types/project-context'
-import { useEditorContext } from '@/shared/context/editor-context'
-import customLocalStorage from '@/infrastructure/local-storage'
-
-type ShareProjectContextValue = {
-  updateProject: (project: ProjectContextUpdateValue) => void
-  monitorRequest: <T extends Promise<unknown>>(request: () => T) => T
-  inFlight: boolean
-  setInFlight: React.Dispatch<
-    React.SetStateAction<ShareProjectContextValue['inFlight']>
-  >
-  error: string | undefined
-  setError: React.Dispatch<
-    React.SetStateAction<ShareProjectContextValue['error']>
-  >
-}
-
-const SHOW_MODAL_COOLDOWN_PERIOD = 24 * 60 * 60 * 1000 // 24 hours
-
-const ShareProjectContext = createContext<ShareProjectContextValue | undefined>(
-  undefined
-)
-
-export function useShareProjectContext() {
-  const context = useContext(ShareProjectContext)
-
-  if (!context) {
-    throw new Error(
-      'useShareProjectContext is only available inside ShareProjectProvider'
-    )
-  }
-
-  return context
-}
-
-type ShareProjectModalProps = {
-  handleHide: () => void
-  show: boolean
-  handleOpen: () => void
-  animation?: boolean
-}
-
-const ShareProjectModal = React.memo(function ShareProjectModal({
-  handleHide,
-  show,
-  handleOpen,
-  animation = true,
-}: ShareProjectModalProps) {
-  const [inFlight, setInFlight] =
-    useState<ShareProjectContextValue['inFlight']>(false)
-  const [error, setError] = useState<ShareProjectContextValue['error']>()
-
-  const project = useProjectContext()
-  const { isProjectOwner } = useEditorContext()
-
-  const { splitTestVariants } = useSplitTestContext()
-
-  // show the new share modal if project owner
-  // is over collaborator limit or has pending editors (once every 24 hours)
-  useEffect(() => {
-    const hasExceededCollaboratorLimit = () => {
-      if (!isProjectOwner || !project.features) {
-        return false
-      }
-
-      if (project.features.collaborators === -1) {
-        return false
-      }
-      return (
-        project.members.filter(member => member.privileges === 'readAndWrite')
-          .length > (project.features.collaborators ?? 1) ||
-        project.members.some(member => member.pendingEditor)
-      )
-    }
-
-    if (hasExceededCollaboratorLimit()) {
-      const localStorageKey = `last-shown-share-modal.${project._id}`
-      const lastShownShareModalTime =
-        customLocalStorage.getItem(localStorageKey)
-      if (
-        !lastShownShareModalTime ||
-        lastShownShareModalTime + SHOW_MODAL_COOLDOWN_PERIOD < Date.now()
-      ) {
-        handleOpen()
-        customLocalStorage.setItem(localStorageKey, Date.now())
-      }
-    }
-  }, [project, isProjectOwner, handleOpen])
-
-  // send tracking event when the modal is opened
-  useEffect(() => {
-    if (show) {
-      sendMB('share-modal-opened', {
-        splitTestVariant: splitTestVariants['null-test-share-modal'],
-        project_id: project._id,
-      })
-    }
-  }, [splitTestVariants, project._id, show])
-
-  // reset error when the modal is opened
-  useEffect(() => {
-    if (show) {
-      setError(undefined)
-    }
-  }, [show])
-
-  // close the modal if not in flight
-  const cancel = useCallback(() => {
-    if (!inFlight) {
-      handleHide()
-    }
-  }, [handleHide, inFlight])
-
-  // update `error` and `inFlight` while sending a request
-  const monitorRequest = useCallback(request => {
-    setError(undefined)
-    setInFlight(true)
-
-    const promise = request()
-
-    promise.catch((error: { data?: Record<string, string> }) => {
-      setError(
-        error.data?.errorReason ||
-          error.data?.error ||
-          'generic_something_went_wrong'
-      )
-    })
-
-    promise.finally(() => {
-      setInFlight(false)
-    })
-
-    return promise
-  }, [])
-
-  // merge the new data with the old project data
-  const updateProject = useCallback(
-    data => Object.assign(project, data),
-    [project]
-  )
-
-  if (!project) {
-    return null
-  }
-
-  return (
-    <ShareProjectContext.Provider
-      value={{
-        updateProject,
-        monitorRequest,
-        inFlight,
-        setInFlight,
-        error,
-        setError,
-      }}
-    >
-      <ShareProjectModalContent
-        animation={animation}
-        cancel={cancel}
-        error={error}
-        inFlight={inFlight}
-        show={show}
-      />
-    </ShareProjectContext.Provider>
-  )
-})
-
-export default ShareProjectModal

+ 0 - 96
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/transfer-ownership-modal.jsx

@@ -1,96 +0,0 @@
-import { useState } from 'react'
-import { Trans, useTranslation } from 'react-i18next'
-import PropTypes from 'prop-types'
-import Icon from '@/shared/components/icon'
-import { transferProjectOwnership } from '../../utils/api'
-import { useProjectContext } from '@/shared/context/project-context'
-import { useLocation } from '@/shared/hooks/use-location'
-import OLModal, {
-  OLModalBody,
-  OLModalFooter,
-  OLModalHeader,
-  OLModalTitle,
-} from '@/features/ui/components/ol/ol-modal'
-import OLNotification from '@/features/ui/components/ol/ol-notification'
-import OLButton from '@/features/ui/components/ol/ol-button'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-import { bsVersion } from '@/features/utils/bootstrap-5'
-import { Spinner } from 'react-bootstrap-5'
-
-export default function TransferOwnershipModal({ member, cancel }) {
-  const { t } = useTranslation()
-
-  const [inflight, setInflight] = useState(false)
-  const [error, setError] = useState(false)
-  const location = useLocation()
-
-  const { _id: projectId, name: projectName } = useProjectContext()
-
-  function confirm() {
-    setError(false)
-    setInflight(true)
-
-    transferProjectOwnership(projectId, member)
-      .then(() => {
-        location.reload()
-      })
-      .catch(() => {
-        setError(true)
-        setInflight(false)
-      })
-  }
-
-  return (
-    <OLModal show onHide={cancel}>
-      <OLModalHeader closeButton>
-        <OLModalTitle>{t('change_project_owner')}</OLModalTitle>
-      </OLModalHeader>
-      <OLModalBody>
-        <p>
-          <Trans
-            i18nKey="project_ownership_transfer_confirmation_1"
-            values={{ user: member.email, project: projectName }}
-            components={[<strong key="strong-1" />, <strong key="strong-2" />]}
-            shouldUnescape
-            tOptions={{ interpolation: { escapeValue: true } }}
-          />
-        </p>
-        <p>{t('project_ownership_transfer_confirmation_2')}</p>
-        {error && (
-          <OLNotification
-            type="error"
-            content={t('generic_something_went_wrong')}
-            className="mb-0 mt-3"
-          />
-        )}
-      </OLModalBody>
-      <OLModalFooter>
-        <div className={bsVersion({ bs3: 'pull-left', bs5: 'me-auto' })}>
-          {inflight && (
-            <BootstrapVersionSwitcher
-              bs3={<Icon type="refresh" spin />}
-              bs5={
-                <Spinner
-                  animation="border"
-                  aria-hidden="true"
-                  size="sm"
-                  role="status"
-                />
-              }
-            />
-          )}
-        </div>
-        <OLButton variant="secondary" onClick={cancel} disabled={inflight}>
-          {t('cancel')}
-        </OLButton>
-        <OLButton variant="primary" onClick={confirm} disabled={inflight}>
-          {t('change_owner')}
-        </OLButton>
-      </OLModalFooter>
-    </OLModal>
-  )
-}
-TransferOwnershipModal.propTypes = {
-  member: PropTypes.object.isRequired,
-  cancel: PropTypes.func.isRequired,
-}

+ 0 - 34
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/view-member.jsx

@@ -1,34 +0,0 @@
-import PropTypes from 'prop-types'
-import MemberPrivileges from './member-privileges'
-import Icon from '@/shared/components/icon'
-import OLRow from '@/features/ui/components/ol/ol-row'
-import OLCol from '@/features/ui/components/ol/ol-col'
-import MaterialIcon from '@/shared/components/material-icon'
-import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
-
-export default function ViewMember({ member }) {
-  return (
-    <OLRow className="project-member">
-      <OLCol xs={8}>
-        <div className="project-member-email-icon">
-          <BootstrapVersionSwitcher
-            bs3={<Icon type="user" fw />}
-            bs5={<MaterialIcon type="person" />}
-          />
-          <div className="email-warning">{member.email}</div>
-        </div>
-      </OLCol>
-      <OLCol xs={4} className="text-end">
-        <MemberPrivileges privileges={member.privileges} />
-      </OLCol>
-    </OLRow>
-  )
-}
-
-ViewMember.propTypes = {
-  member: PropTypes.shape({
-    _id: PropTypes.string.isRequired,
-    email: PropTypes.string.isRequired,
-    privileges: PropTypes.string.isRequired,
-  }).isRequired,
-}

+ 7 - 4
services/web/frontend/js/features/share-project-modal/components/select-collaborators.jsx

@@ -5,7 +5,7 @@ import { matchSorter } from 'match-sorter'
 import { useCombobox } from 'downshift'
 import classnames from 'classnames'
 
-import Icon from '../../../shared/components/icon'
+import Icon from '@/shared/components/icon'
 import MaterialIcon from '@/shared/components/material-icon'
 import Tag from '@/features/ui/components/bootstrap-5/tag'
 import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
@@ -155,11 +155,13 @@ export default function SelectCollaborators({
   }, [reset, isOpen, filteredOptions.length])
 
   return (
-    <div className="tags-input">
+    <div className="tags-input tags-new">
       {/* eslint-disable-next-line jsx-a11y/label-has-for */}
       <label className="small" {...getLabelProps()}>
-        {t('share_with_your_collabs')}
-        &nbsp;
+        <strong>
+          {t('add_people')}
+          &nbsp;
+        </strong>
         {loading && (
           <BootstrapVersionSwitcher
             bs3={<Icon type="refresh" spin />}
@@ -212,6 +214,7 @@ export default function SelectCollaborators({
                 onChange: e => {
                   setInputValue(e.target.value)
                 },
+                onClick: () => focusInput,
                 onKeyDown: event => {
                   switch (event.key) {
                     case 'Enter':

+ 0 - 34
services/web/frontend/js/features/share-project-modal/components/send-invites-notice.jsx

@@ -1,34 +0,0 @@
-import PropTypes from 'prop-types'
-import { useTranslation } from 'react-i18next'
-import { useProjectContext } from '../../../shared/context/project-context'
-import OLRow from '@/features/ui/components/ol/ol-row'
-import OLCol from '@/features/ui/components/ol/ol-col'
-
-export default function SendInvitesNotice() {
-  const { publicAccessLevel } = useProjectContext()
-
-  return (
-    <OLRow className="public-access-level public-access-level-notice">
-      <OLCol className="text-center">
-        <AccessLevel level={publicAccessLevel} />
-      </OLCol>
-    </OLRow>
-  )
-}
-
-function AccessLevel({ level }) {
-  const { t } = useTranslation()
-  switch (level) {
-    case 'private':
-      return t('to_add_more_collaborators')
-
-    case 'tokenBased':
-      return t('to_change_access_permissions')
-
-    default:
-      return null
-  }
-}
-AccessLevel.propTypes = {
-  level: PropTypes.string,
-}

+ 1 - 1
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites-notice.tsx → services/web/frontend/js/features/share-project-modal/components/send-invites-notice.tsx

@@ -1,7 +1,7 @@
 import { useTranslation } from 'react-i18next'
 import { useProjectContext } from '@/shared/context/project-context'
 import Notification from '@/shared/components/notification'
-import { PublicAccessLevel } from '../../../../../../types/public-access-level'
+import { PublicAccessLevel } from '../../../../../types/public-access-level'
 import { useEditorContext } from '@/shared/context/editor-context'
 import OLRow from '@/features/ui/components/ol/ol-row'
 import OLCol from '@/features/ui/components/ol/ol-col'

+ 25 - 2
services/web/frontend/js/features/share-project-modal/components/send-invites.jsx

@@ -1,16 +1,39 @@
 import AddCollaborators from './add-collaborators'
 import AddCollaboratorsUpgrade from './add-collaborators-upgrade'
+import CollaboratorsLimitUpgrade from './collaborators-limit-upgrade'
+import AccessLevelsChanged from './access-levels-changed'
 import PropTypes from 'prop-types'
 import OLRow from '@/features/ui/components/ol/ol-row'
 
-export default function SendInvites({ canAddCollaborators }) {
+export default function SendInvites({
+  canAddCollaborators,
+  hasExceededCollaboratorLimit,
+  haveAnyEditorsBeenDowngraded,
+  somePendingEditorsResolved,
+}) {
   return (
     <OLRow className="invite-controls">
-      {canAddCollaborators ? <AddCollaborators /> : <AddCollaboratorsUpgrade />}
+      {hasExceededCollaboratorLimit && !haveAnyEditorsBeenDowngraded && (
+        <AddCollaboratorsUpgrade />
+      )}
+
+      {haveAnyEditorsBeenDowngraded && (
+        <AccessLevelsChanged
+          somePendingEditorsResolved={somePendingEditorsResolved}
+        />
+      )}
+
+      {!canAddCollaborators &&
+        !hasExceededCollaboratorLimit &&
+        !haveAnyEditorsBeenDowngraded && <CollaboratorsLimitUpgrade />}
+      <AddCollaborators readOnly={!canAddCollaborators} />
     </OLRow>
   )
 }
 
 SendInvites.propTypes = {
   canAddCollaborators: PropTypes.bool,
+  hasExceededCollaboratorLimit: PropTypes.bool,
+  haveAnyEditorsBeenDowngraded: PropTypes.bool,
+  somePendingEditorsResolved: PropTypes.bool,
 }

+ 75 - 12
services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx

@@ -5,10 +5,10 @@ import SendInvites from './send-invites'
 import ViewMember from './view-member'
 import OwnerInfo from './owner-info'
 import SendInvitesNotice from './send-invites-notice'
-import { useEditorContext } from '../../../shared/context/editor-context'
-import { useProjectContext } from '../../../shared/context/project-context'
+import { useEditorContext } from '@/shared/context/editor-context'
+import { useProjectContext } from '@/shared/context/project-context'
 import { useMemo } from 'react'
-import RecaptchaConditions from '../../../shared/components/recaptcha-conditions'
+import RecaptchaConditions from '@/shared/components/recaptcha-conditions'
 import getMeta from '@/utils/meta'
 
 export default function ShareModalBody() {
@@ -26,18 +26,87 @@ export default function ShareModalBody() {
       return true
     }
 
-    return members.length + invites.length < (features.collaborators ?? 1)
+    const editorInvites = invites.filter(
+      invite => invite.privileges === 'readAndWrite'
+    ).length
+
+    return (
+      members.filter(member => member.privileges === 'readAndWrite').length +
+        editorInvites <
+      (features.collaborators ?? 1)
+    )
   }, [members, invites, features, isProjectOwner])
 
+  // determine if some but not all pending editors' permissions have been resolved,
+  // for moving between warning and info notification states etc.
+  const somePendingEditorsResolved = useMemo(() => {
+    return (
+      members.some(member => member.privileges === 'readAndWrite') &&
+      members.some(member => member.pendingEditor)
+    )
+  }, [members])
+
+  const haveAnyEditorsBeenDowngraded = useMemo(() => {
+    if (!isProjectOwner || !features) {
+      return false
+    }
+
+    if (features.collaborators === -1) {
+      return false
+    }
+    return members.some(member => member.pendingEditor)
+  }, [features, isProjectOwner, members])
+
+  const hasExceededCollaboratorLimit = useMemo(() => {
+    if (!isProjectOwner || !features) {
+      return false
+    }
+
+    if (features.collaborators === -1) {
+      return false
+    }
+
+    return (
+      members.filter(member => member.privileges === 'readAndWrite').length >
+      (features.collaborators ?? 1)
+    )
+  }, [features, isProjectOwner, members])
+
+  const sortedMembers = useMemo(() => {
+    return [
+      ...members.filter(member => member.privileges === 'readAndWrite'),
+      ...members.filter(member => member.pendingEditor),
+      ...members.filter(
+        member => !member.pendingEditor && member.privileges !== 'readAndWrite'
+      ),
+    ]
+  }, [members])
+
   return (
     <>
+      {isProjectOwner ? (
+        <SendInvites
+          canAddCollaborators={canAddCollaborators}
+          hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
+          haveAnyEditorsBeenDowngraded={haveAnyEditorsBeenDowngraded}
+          somePendingEditorsResolved={somePendingEditorsResolved}
+        />
+      ) : (
+        <SendInvitesNotice />
+      )}
       {isProjectOwner && <LinkSharing />}
 
       <OwnerInfo />
 
-      {members.map(member =>
+      {sortedMembers.map(member =>
         isProjectOwner ? (
-          <EditMember key={member._id} member={member} />
+          <EditMember
+            key={member._id}
+            member={member}
+            hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
+            hasBeenDowngraded={member.pendingEditor ?? false}
+            canAddCollaborators={canAddCollaborators}
+          />
         ) : (
           <ViewMember key={member._id} member={member} />
         )
@@ -51,12 +120,6 @@ export default function ShareModalBody() {
         />
       ))}
 
-      {isProjectOwner ? (
-        <SendInvites canAddCollaborators={canAddCollaborators} />
-      ) : (
-        <SendInvitesNotice />
-      )}
-
       {!getMeta('ol-ExposedSettings').recaptchaDisabled?.invite && (
         <RecaptchaConditions />
       )}

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

@@ -1,5 +1,5 @@
 import { useTranslation } from 'react-i18next'
-import Icon from '../../../shared/components/icon'
+import Icon from '@/shared/components/icon'
 import { useEditorContext } from '@/shared/context/editor-context'
 import { lazy, Suspense } from 'react'
 import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
@@ -50,7 +50,7 @@ export default function ShareProjectModalContent({
         <OLModalTitle>{t('share_project')}</OLModalTitle>
       </OLModalHeader>
 
-      <OLModalBody className="modal-body-share">
+      <OLModalBody className="modal-body-share modal-link-share-new">
         <div className="container-fluid">
           <Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
             {isRestrictedTokenMember ? (

+ 44 - 5
services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx

@@ -6,13 +6,15 @@ import React, {
   useState,
 } from 'react'
 import ShareProjectModalContent from './share-project-modal-content'
-import { useProjectContext } from '../../../shared/context/project-context'
-import { useSplitTestContext } from '../../../shared/context/split-test-context'
-import { sendMB } from '../../../infrastructure/event-tracking'
-import { Project } from '../../../../../types/project'
+import { useProjectContext } from '@/shared/context/project-context'
+import { useSplitTestContext } from '@/shared/context/split-test-context'
+import { sendMB } from '@/infrastructure/event-tracking'
+import { ProjectContextUpdateValue } from '@/shared/context/types/project-context'
+import { useEditorContext } from '@/shared/context/editor-context'
+import customLocalStorage from '@/infrastructure/local-storage'
 
 type ShareProjectContextValue = {
-  updateProject: (project: Project) => void
+  updateProject: (project: ProjectContextUpdateValue) => void
   monitorRequest: <T extends Promise<unknown>>(request: () => T) => T
   inFlight: boolean
   setInFlight: React.Dispatch<
@@ -24,6 +26,8 @@ type ShareProjectContextValue = {
   >
 }
 
+const SHOW_MODAL_COOLDOWN_PERIOD = 24 * 60 * 60 * 1000 // 24 hours
+
 const ShareProjectContext = createContext<ShareProjectContextValue | undefined>(
   undefined
 )
@@ -43,12 +47,14 @@ export function useShareProjectContext() {
 type ShareProjectModalProps = {
   handleHide: () => void
   show: boolean
+  handleOpen: () => void
   animation?: boolean
 }
 
 const ShareProjectModal = React.memo(function ShareProjectModal({
   handleHide,
   show,
+  handleOpen,
   animation = true,
 }: ShareProjectModalProps) {
   const [inFlight, setInFlight] =
@@ -56,9 +62,42 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
   const [error, setError] = useState<ShareProjectContextValue['error']>()
 
   const project = useProjectContext()
+  const { isProjectOwner } = useEditorContext()
 
   const { splitTestVariants } = useSplitTestContext()
 
+  // show the new share modal if project owner
+  // is over collaborator limit or has pending editors (once every 24 hours)
+  useEffect(() => {
+    const hasExceededCollaboratorLimit = () => {
+      if (!isProjectOwner || !project.features) {
+        return false
+      }
+
+      if (project.features.collaborators === -1) {
+        return false
+      }
+      return (
+        project.members.filter(member => member.privileges === 'readAndWrite')
+          .length > (project.features.collaborators ?? 1) ||
+        project.members.some(member => member.pendingEditor)
+      )
+    }
+
+    if (hasExceededCollaboratorLimit()) {
+      const localStorageKey = `last-shown-share-modal.${project._id}`
+      const lastShownShareModalTime =
+        customLocalStorage.getItem(localStorageKey)
+      if (
+        !lastShownShareModalTime ||
+        lastShownShareModalTime + SHOW_MODAL_COOLDOWN_PERIOD < Date.now()
+      ) {
+        handleOpen()
+        customLocalStorage.setItem(localStorageKey, Date.now())
+      }
+    }
+  }, [project, isProjectOwner, handleOpen])
+
   // send tracking event when the modal is opened
   useEffect(() => {
     if (show) {

+ 3 - 3
services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.jsx

@@ -1,10 +1,10 @@
 import { useState } from 'react'
 import { Trans, useTranslation } from 'react-i18next'
 import PropTypes from 'prop-types'
-import Icon from '../../../shared/components/icon'
+import Icon from '@/shared/components/icon'
 import { transferProjectOwnership } from '../utils/api'
-import { useProjectContext } from '../../../shared/context/project-context'
-import { useLocation } from '../../../shared/hooks/use-location'
+import { useProjectContext } from '@/shared/context/project-context'
+import { useLocation } from '@/shared/hooks/use-location'
 import OLModal, {
   OLModalBody,
   OLModalFooter,

+ 13 - 2
services/web/frontend/js/features/share-project-modal/components/view-member.jsx

@@ -1,13 +1,24 @@
 import PropTypes from 'prop-types'
 import MemberPrivileges from './member-privileges'
+import Icon from '@/shared/components/icon'
 import OLRow from '@/features/ui/components/ol/ol-row'
 import OLCol from '@/features/ui/components/ol/ol-col'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 export default function ViewMember({ member }) {
   return (
     <OLRow className="project-member">
-      <OLCol xs={7}>{member.email}</OLCol>
-      <OLCol xs={3}>
+      <OLCol xs={8}>
+        <div className="project-member-email-icon">
+          <BootstrapVersionSwitcher
+            bs3={<Icon type="user" fw />}
+            bs5={<MaterialIcon type="person" />}
+          />
+          <div className="email-warning">{member.email}</div>
+        </div>
+      </OLCol>
+      <OLCol xs={4} className="text-end">
         <MemberPrivileges privileges={member.privileges} />
       </OLCol>
     </OLRow>

+ 0 - 0
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/view-only-access-modal-content.tsx → services/web/frontend/js/features/share-project-modal/components/view-only-access-modal-content.tsx


+ 0 - 0
services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/view-only-access-modal.tsx → services/web/frontend/js/features/share-project-modal/components/view-only-access-modal.tsx


+ 0 - 5
services/web/locales/en.json

@@ -85,7 +85,6 @@
   "add_error_assist_annual_to_your_projects": "Add Error Assist Annual <sparkle/> to your projects and get unlimited AI help to fix LaTeX errors faster.",
   "add_error_assist_to_your_projects": "Add Error Assist <sparkle/> to your projects and get unlimited AI help to fix LaTeX errors faster.",
   "add_files": "Add Files",
-  "add_more_collaborators": "Add more collaborators",
   "add_more_editors": "Add more editors",
   "add_more_managers": "Add more managers",
   "add_more_members": "Add more members",
@@ -1148,7 +1147,6 @@
   "link_accounts_and_add_email": "Link Accounts and Add Email",
   "link_institutional_email_get_started": "Link an institutional email address to your account to get started.",
   "link_sharing": "Link sharing",
-  "link_sharing_is_off": "Link sharing is off, only invited users can view this project.",
   "link_sharing_is_off_short": "Link sharing is off",
   "link_sharing_is_on": "Link sharing is on",
   "link_to_github": "Link to your GitHub account",
@@ -1315,7 +1313,6 @@
   "need_more_than_x_licenses": "Need more than __x__ licenses?",
   "need_to_add_new_primary_before_remove": "You’ll need to add a new primary email address before you can remove this one.",
   "need_to_leave": "Need to leave?",
-  "need_to_upgrade_for_more_collabs": "You need to upgrade your account to add more collaborators",
   "new_compile_domain_notice": "We’ve recently migrated PDF downloads to a new domain. Something might be blocking your browser from accessing that new domain, <0>__compilesUserContentDomain__</0>. This could be caused by network blocking or a strict browser plugin rule. Please follow our <1>troubleshooting guide</1>.",
   "new_file": "New file",
   "new_folder": "New folder",
@@ -1719,7 +1716,6 @@
   "remove": "Remove",
   "remove_access": "Remove access",
   "remove_add_on": "Remove add-on",
-  "remove_collaborator": "Remove collaborator",
   "remove_from_group": "Remove from group",
   "remove_link": "Remove link",
   "remove_manager": "Remove manager",
@@ -1916,7 +1912,6 @@
   "setup_another_account_under_a_personal_email_address": "Set up another Overleaf account under a personal email address.",
   "share": "Share",
   "share_project": "Share Project",
-  "share_with_your_collabs": "Share with your collaborators",
   "shared_with_you": "Shared with you",
   "sharelatex_beta_program": "__appName__ Beta Program",
   "shortcut_to_open_advanced_reference_search": "(<strong>__ctrlSpace__</strong> or <strong>__altSpace__</strong>)",

+ 78 - 67
services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.jsx

@@ -1,14 +1,9 @@
 import { expect } from 'chai'
 import sinon from 'sinon'
-import {
-  screen,
-  fireEvent,
-  render,
-  waitFor,
-  waitForElementToBeRemoved,
-} from '@testing-library/react'
+import { screen, fireEvent, render, waitFor } from '@testing-library/react'
 import fetchMock from 'fetch-mock'
 import userEvent from '@testing-library/user-event'
+import * as bootstrapUtils from '@/features/utils/bootstrap-5'
 
 import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
 import {
@@ -22,6 +17,15 @@ import {
 } from '../../../helpers/editor-providers'
 import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
 
+async function changePrivilegeLevel(screen, { current, next }) {
+  const select = screen.getByDisplayValue(current)
+  await fireEvent.click(select)
+  const option = screen.getByRole('option', {
+    name: next,
+  })
+  await fireEvent.click(option)
+}
+
 describe('<ShareProjectModal/>', function () {
   const project = {
     _id: 'test-project',
@@ -91,6 +95,9 @@ describe('<ShareProjectModal/>', function () {
       replace: sinon.stub(),
       reload: sinon.stub(),
     })
+    this.isBootstrap5Stub = sinon
+      .stub(bootstrapUtils, 'isBootstrap5')
+      .returns(true)
     fetchMock.get('/user/contacts', { contacts })
     window.metaAttributesCache.set('ol-user', { allowedFreeTrial: true })
     window.metaAttributesCache.set('ol-showUpgradePrompt', true)
@@ -98,6 +105,7 @@ describe('<ShareProjectModal/>', function () {
 
   afterEach(function () {
     this.locationStub.restore()
+    this.isBootstrap5Stub.restore()
     fetchMock.restore()
     cleanUpContext()
   })
@@ -134,9 +142,7 @@ describe('<ShareProjectModal/>', function () {
       scope: { project: { ...project, publicAccesLevel: 'private' } },
     })
 
-    await screen.findByText(
-      'Link sharing is off, only invited users can view this project.'
-    )
+    await screen.findByText('Link sharing is off')
     await screen.findByRole('button', { name: 'Turn on link sharing' })
 
     expect(screen.queryByText('Anyone with this link can view this project')).to
@@ -196,7 +202,7 @@ describe('<ShareProjectModal/>', function () {
     await screen.findByRole('button', { name: 'Make Private' })
   })
 
-  it('hides actions from non-project-owners', async function () {
+  it('displays actions for project-owners', async function () {
     const invites = [
       {
         _id: 'invited-author',
@@ -222,8 +228,17 @@ describe('<ShareProjectModal/>', function () {
 
     await screen.findByRole('button', { name: 'Turn off link sharing' })
     await screen.findByRole('button', { name: 'Resend' })
+  })
+
+  it('hides actions from non-project-owners when link sharing on', async function () {
+    const invites = [
+      {
+        _id: 'invited-author',
+        email: 'invited-author@example.com',
+        privileges: 'readAndWrite',
+      },
+    ]
 
-    // render as non-project-owner, link sharing on: actions should be missing and message should be present
     render(
       <EditorProviders
         scope={{
@@ -251,8 +266,17 @@ describe('<ShareProjectModal/>', function () {
     expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
       .null
     expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null
+  })
+
+  it('hides actions from non-project-owners when link sharing off', async function () {
+    const invites = [
+      {
+        _id: 'invited-author',
+        email: 'invited-author@example.com',
+        privileges: 'readAndWrite',
+      },
+    ]
 
-    // render as non-project-owner, link sharing off: actions should be missing and message should be present
     render(
       <EditorProviders
         scope={{
@@ -342,6 +366,7 @@ describe('<ShareProjectModal/>', function () {
 
     const projectOwnerEmail = USER_EMAIL
 
+    await screen.findByText(projectOwnerEmail)
     expect(screen.queryAllByText(projectOwnerEmail)).to.have.length(1)
     expect(screen.queryAllByText('member-author@example.com')).to.have.length(1)
     expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
@@ -455,14 +480,12 @@ describe('<ShareProjectModal/>', function () {
       name: 'Close',
     })
 
-    expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
-
-    const select = screen.getByDisplayValue('Read only')
-    await fireEvent.change(select, { target: { value: 'readAndWrite' } })
+    expect(
+      await screen.findAllByText('member-viewer@example.com')
+    ).to.have.length(1)
 
-    const changeButton = screen.getByRole('button', { name: 'Change' })
+    await changePrivilegeLevel(screen, { current: 'Viewer', next: 'Editor' })
 
-    fireEvent.click(changeButton)
     await waitFor(() => expect(closeButton.disabled).to.be.true)
 
     const { body } = fetchMock.lastOptions()
@@ -493,22 +516,23 @@ describe('<ShareProjectModal/>', function () {
       },
     })
 
-    expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
+    expect(
+      await screen.findAllByText('member-viewer@example.com')
+    ).to.have.length(1)
 
+    await changePrivilegeLevel(screen, {
+      current: 'Viewer',
+      next: 'Remove access',
+    })
     const removeButton = screen.getByRole('button', {
-      name: 'Remove collaborator',
+      name: 'Change',
     })
-
     fireEvent.click(removeButton)
 
     const url = fetchMock.lastUrl()
     expect(url).to.equal('/project/test-project/users/member-viewer')
 
     expect(fetchMock.done()).to.be.true
-
-    await waitForElementToBeRemoved(() =>
-      screen.queryByText('member-viewer@example.com')
-    )
   })
 
   it('changes member privileges to owner with confirmation', async function () {
@@ -532,13 +556,13 @@ describe('<ShareProjectModal/>', function () {
       },
     })
 
+    await screen.findByText('member-viewer@example.com')
     expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
 
-    const select = screen.getByDisplayValue('Read only')
-    fireEvent.change(select, { target: { value: 'owner' } })
-
-    const changeButton = screen.getByRole('button', { name: 'Change' })
-    await fireEvent.click(changeButton)
+    await changePrivilegeLevel(screen, {
+      current: 'Viewer',
+      next: 'Make owner',
+    })
 
     screen.getByText((_, node) => {
       return (
@@ -569,9 +593,7 @@ describe('<ShareProjectModal/>', function () {
       },
     })
 
-    const [inputElement] = await screen.findAllByLabelText(
-      'Share with your collaborators'
-    )
+    const [inputElement] = await screen.findAllByLabelText('Add people')
 
     // loading contacts
     await waitFor(() => {
@@ -618,7 +640,7 @@ describe('<ShareProjectModal/>', function () {
     const privilegesElement = screen.getByDisplayValue('Can edit')
     fireEvent.change(privilegesElement, { target: { value: 'readOnly' } })
 
-    const submitButton = screen.getByRole('button', { name: 'Share' })
+    const submitButton = screen.getByRole('button', { name: 'Invite' })
     await userEvent.click(submitButton)
 
     let calls
@@ -667,10 +689,13 @@ describe('<ShareProjectModal/>', function () {
       },
     })
 
-    expect(screen.queryByLabelText('Share with your collaborators')).to.be.null
+    await screen.findByText('Add more editors')
+    expect(screen.getByRole('option', { name: 'Can edit' }).disabled).to.be.true
+    expect(screen.getByRole('option', { name: 'Can view' }).disabled).to.be
+      .false
 
     screen.getByText(
-      /You need to upgrade your account to add more collaborators/
+      /Upgrade to add more editors and access collaboration features like track changes and full project history/
     )
   })
 
@@ -689,11 +714,9 @@ describe('<ShareProjectModal/>', function () {
       expect(fetchMock.called('express:/user/contacts')).to.be.true
     })
 
-    const [inputElement] = await screen.findAllByLabelText(
-      'Share with your collaborators'
-    )
+    const [inputElement] = await screen.findAllByLabelText('Add people')
 
-    const submitButton = screen.getByRole('button', { name: 'Share' })
+    const submitButton = screen.getByRole('button', { name: 'Invite' })
 
     const respondWithError = async function (errorReason) {
       fireEvent.focus(inputElement)
@@ -748,9 +771,7 @@ describe('<ShareProjectModal/>', function () {
       },
     })
 
-    await screen.findByText(
-      'Link sharing is off, only invited users can view this project.'
-    )
+    await screen.findByText('Link sharing is off')
 
     const enableButton = await screen.findByRole('button', {
       name: 'Turn on link sharing',
@@ -791,9 +812,7 @@ describe('<ShareProjectModal/>', function () {
     })
     // watchCallbacks.project({ ...project, publicAccesLevel: 'private' })
 
-    await screen.findByText(
-      'Link sharing is off, only invited users can view this project.'
-    )
+    await screen.findByText('Link sharing is off')
   })
 
   it('avoids selecting unmatched contact', async function () {
@@ -801,9 +820,7 @@ describe('<ShareProjectModal/>', function () {
       scope: { project },
     })
 
-    const [inputElement] = await screen.findAllByLabelText(
-      'Share with your collaborators'
-    )
+    const [inputElement] = await screen.findAllByLabelText('Add people')
 
     // Wait for contacts to load
     await waitFor(() => {
@@ -838,7 +855,7 @@ describe('<ShareProjectModal/>', function () {
     // Pressing Tab should add the entered item
     fireEvent.keyDown(inputElement, { key: 'Tab', code: 'Tab' })
     await waitFor(() => {
-      expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
+      expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
         1
       )
     })
@@ -846,7 +863,7 @@ describe('<ShareProjectModal/>', function () {
     // Blurring the input should not add another contact
     fireEvent.blur(inputElement)
     await waitFor(() => {
-      expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
+      expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
         1
       )
     })
@@ -857,9 +874,7 @@ describe('<ShareProjectModal/>', function () {
       scope: { project },
     })
 
-    const [inputElement] = await screen.findAllByLabelText(
-      'Share with your collaborators'
-    )
+    const [inputElement] = await screen.findAllByLabelText('Add people')
 
     // Wait for contacts to load
     await waitFor(() => {
@@ -876,14 +891,14 @@ describe('<ShareProjectModal/>', function () {
     })
 
     // No items should be added yet
-    expect(screen.queryByRole('button', { name: 'Remove' })).to.be.null
+    expect(screen.queryByRole('button', { name: /Remove/ })).to.be.null
 
     // Click anywhere on the form to blur the input
     await userEvent.click(screen.getByRole('dialog'))
 
     // The contact should be added
     await waitFor(() => {
-      expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
+      expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
         1
       )
     })
@@ -894,9 +909,7 @@ describe('<ShareProjectModal/>', function () {
       scope: { project },
     })
 
-    const [inputElement] = await screen.findAllByLabelText(
-      'Share with your collaborators'
-    )
+    const [inputElement] = await screen.findAllByLabelText('Add people')
 
     // Wait for contacts to load
     await waitFor(() => {
@@ -919,7 +932,7 @@ describe('<ShareProjectModal/>', function () {
 
     // The contact should be added
     await waitFor(() => {
-      expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
+      expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
         1
       )
     })
@@ -930,9 +943,7 @@ describe('<ShareProjectModal/>', function () {
       scope: { project },
     })
 
-    const [inputElement] = await screen.findAllByLabelText(
-      'Share with your collaborators'
-    )
+    const [inputElement] = await screen.findAllByLabelText('Add people')
 
     // Wait for contacts to load
     await waitFor(() => {
@@ -954,10 +965,10 @@ describe('<ShareProjectModal/>', function () {
     await userEvent.click(screen.getByRole('dialog'))
 
     // Remove the just-added collaborator
-    await userEvent.click(screen.getByRole('button', { name: 'Remove' }))
+    await userEvent.click(screen.getByRole('button', { name: /Remove/ }))
 
     // Remove button should now be gone
-    expect(screen.queryByRole('button', { name: 'Remove' })).to.be.null
+    expect(screen.queryByRole('button', { name: /Remove/ })).to.be.null
 
     // Add the same collaborator again
     await userEvent.type(inputElement, 'pto')
@@ -975,7 +986,7 @@ describe('<ShareProjectModal/>', function () {
 
     // The contact should be added
     await waitFor(() => {
-      expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
+      expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
         1
       )
     })