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

Merge pull request #18861 from overleaf/rh-editor-limit-exceeded

[web]: Handle exceeded editor limit in share modal

GitOrigin-RevId: 23a15805ca98327ae4a7fc731bbca3982c90bad5
roo hutton 2 лет назад
Родитель
Сommit
64d9792fe3
32 измененных файлов с 2135 добавлено и 70 удалено
  1. 7 0
      services/web/app/src/Features/Project/ProjectController.js
  2. 1 0
      services/web/app/views/project/editor/meta.pug
  3. 14 0
      services/web/frontend/extracted-translations.json
  4. 15 4
      services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx
  5. 36 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators-upgrade.tsx
  6. 174 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators.jsx
  7. 28 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/collaborators-limit-upgrade.tsx
  8. 242 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/edit-member.tsx
  9. 121 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/invite.jsx
  10. 311 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/link-sharing.jsx
  11. 20 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/member-privileges.jsx
  12. 23 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/owner-info.jsx
  13. 357 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/select-collaborators.jsx
  14. 33 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites-notice.jsx
  15. 27 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites.jsx
  16. 90 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-modal-body.tsx
  17. 104 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal-content.tsx
  18. 140 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal.tsx
  19. 86 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/transfer-ownership-modal.jsx
  20. 22 0
      services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/view-member.jsx
  21. 52 17
      services/web/frontend/js/shared/components/select.tsx
  22. 2 38
      services/web/frontend/js/shared/context/project-context.tsx
  23. 44 0
      services/web/frontend/js/shared/context/types/project-context.tsx
  24. 1 0
      services/web/frontend/js/utils/meta.ts
  25. 26 0
      services/web/frontend/stories/select.stories.tsx
  26. 67 7
      services/web/frontend/stylesheets/app/editor/share.less
  27. 18 2
      services/web/frontend/stylesheets/components/select.less
  28. 4 1
      services/web/frontend/stylesheets/components/tags-input.less
  29. 14 0
      services/web/locales/en.json
  30. 54 0
      services/web/test/frontend/components/shared/select.spec.tsx
  31. 1 0
      services/web/test/unit/src/Project/ProjectControllerTests.js
  32. 1 1
      services/web/types/project.ts

+ 7 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -578,6 +578,12 @@ const _ProjectController = {
           ? 'project/ide-react-detached'
           ? 'project/ide-react-detached'
           : 'project/ide-react'
           : 'project/ide-react'
 
 
+      const assignLink = await SplitTestHandler.promises.getAssignmentForUser(
+        project.owner_ref,
+        'link-sharing-warning'
+      )
+      const linkSharingWarning = assignLink.variant === 'active'
+
       res.render(template, {
       res.render(template, {
         title: project.name,
         title: project.name,
         priority_title: true,
         priority_title: true,
@@ -651,6 +657,7 @@ const _ProjectController = {
         optionalPersonalAccessToken,
         optionalPersonalAccessToken,
         hasTrackChangesFeature: Features.hasFeature('track-changes'),
         hasTrackChangesFeature: Features.hasFeature('track-changes'),
         projectTags,
         projectTags,
+        linkSharingWarning,
       })
       })
       timer.done()
       timer.done()
     } catch (err) {
     } catch (err) {

+ 1 - 0
services/web/app/views/project/editor/meta.pug

@@ -36,6 +36,7 @@ meta(name="ol-optionalPersonalAccessToken", data-type="boolean" content=optional
 meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature)
 meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature)
 meta(name="ol-inactiveTutorials", data-type="json" content=user.inactiveTutorials)
 meta(name="ol-inactiveTutorials", data-type="json" content=user.inactiveTutorials)
 meta(name="ol-projectTags" data-type="json" content=projectTags)
 meta(name="ol-projectTags" data-type="json" content=projectTags)
+meta(name="ol-linkSharingWarning" data-type="boolean" content=linkSharingWarning)
 meta(name="ol-loadingText", data-type="string" content=translate("loading"))
 meta(name="ol-loadingText", data-type="string" content=translate("loading"))
 meta(name="ol-translationLoadErrorMessage", data-type="string" content=translate("could_not_load_translations"))
 meta(name="ol-translationLoadErrorMessage", data-type="string" content=translate("could_not_load_translations"))
 
 

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

@@ -51,6 +51,7 @@
   "add_more_members": "",
   "add_more_members": "",
   "add_new_email": "",
   "add_new_email": "",
   "add_or_remove_project_from_tag": "",
   "add_or_remove_project_from_tag": "",
+  "add_people": "",
   "add_role_and_department": "",
   "add_role_and_department": "",
   "add_to_tag": "",
   "add_to_tag": "",
   "add_your_comment_here": "",
   "add_your_comment_here": "",
@@ -359,8 +360,10 @@
   "edit_tag": "",
   "edit_tag": "",
   "editing": "",
   "editing": "",
   "editing_captions": "",
   "editing_captions": "",
+  "editor": "",
   "editor_and_pdf": "",
   "editor_and_pdf": "",
   "editor_disconected_click_to_reconnect": "",
   "editor_disconected_click_to_reconnect": "",
+  "editor_limit_exceeded_in_this_project": "",
   "editor_only_hide_pdf": "",
   "editor_only_hide_pdf": "",
   "editor_theme": "",
   "editor_theme": "",
   "educational_discount_for_groups_of_x_or_more": "",
   "educational_discount_for_groups_of_x_or_more": "",
@@ -647,6 +650,7 @@
   "invalid_password_contains_email": "",
   "invalid_password_contains_email": "",
   "invalid_password_too_similar": "",
   "invalid_password_too_similar": "",
   "invalid_request": "",
   "invalid_request": "",
+  "invite": "",
   "invite_more_collabs": "",
   "invite_more_collabs": "",
   "invite_not_accepted": "",
   "invite_not_accepted": "",
   "invited_to_group": "",
   "invited_to_group": "",
@@ -703,6 +707,7 @@
   "library": "",
   "library": "",
   "license_for_educational_purposes": "",
   "license_for_educational_purposes": "",
   "limited_offer": "",
   "limited_offer": "",
+  "limited_to_n_editors_per_project": "",
   "line_height": "",
   "line_height": "",
   "line_width_is_the_width_of_the_line_in_the_current_environment": "",
   "line_width_is_the_width_of_the_line_in_the_current_environment": "",
   "link": "",
   "link": "",
@@ -712,6 +717,7 @@
   "link_institutional_email_get_started": "",
   "link_institutional_email_get_started": "",
   "link_sharing": "",
   "link_sharing": "",
   "link_sharing_is_off": "",
   "link_sharing_is_off": "",
+  "link_sharing_is_off_short": "",
   "link_sharing_is_on": "",
   "link_sharing_is_on": "",
   "link_to_github": "",
   "link_to_github": "",
   "link_to_github_description": "",
   "link_to_github_description": "",
@@ -747,6 +753,7 @@
   "main_file_not_found": "",
   "main_file_not_found": "",
   "make_a_copy": "",
   "make_a_copy": "",
   "make_email_primary_description": "",
   "make_email_primary_description": "",
+  "make_owner": "",
   "make_primary": "",
   "make_primary": "",
   "make_private": "",
   "make_private": "",
   "manage_beta_program_membership": "",
   "manage_beta_program_membership": "",
@@ -1014,6 +1021,7 @@
   "react_history_tutorial_title": "",
   "react_history_tutorial_title": "",
   "reactivate_subscription": "",
   "reactivate_subscription": "",
   "read_lines_from_path": "",
   "read_lines_from_path": "",
+  "read_more": "",
   "read_more_about_free_compile_timeouts_servers": "",
   "read_more_about_free_compile_timeouts_servers": "",
   "read_only": "",
   "read_only": "",
   "read_only_token": "",
   "read_only_token": "",
@@ -1053,6 +1061,7 @@
   "remind_before_trial_ends": "",
   "remind_before_trial_ends": "",
   "remote_service_error": "",
   "remote_service_error": "",
   "remove": "",
   "remove": "",
+  "remove_access": "",
   "remove_collaborator": "",
   "remove_collaborator": "",
   "remove_from_group": "",
   "remove_from_group": "",
   "remove_link": "",
   "remove_link": "",
@@ -1522,6 +1531,7 @@
   "upgrade_cc_btn": "",
   "upgrade_cc_btn": "",
   "upgrade_for_12x_more_compile_time": "",
   "upgrade_for_12x_more_compile_time": "",
   "upgrade_now": "",
   "upgrade_now": "",
+  "upgrade_to_add_more_editors": "",
   "upgrade_to_get_feature": "",
   "upgrade_to_get_feature": "",
   "upgrade_to_track_changes": "",
   "upgrade_to_track_changes": "",
   "upload": "",
   "upload": "",
@@ -1569,6 +1579,7 @@
   "view_options": "",
   "view_options": "",
   "view_pdf": "",
   "view_pdf": "",
   "view_your_invoices": "",
   "view_your_invoices": "",
+  "viewer": "",
   "viewing_x": "",
   "viewing_x": "",
   "visual_editor": "",
   "visual_editor": "",
   "visual_editor_is_only_available_for_tex_files": "",
   "visual_editor_is_only_available_for_tex_files": "",
@@ -1590,6 +1601,7 @@
   "what_should_we_call_you": "",
   "what_should_we_call_you": "",
   "when_you_tick_the_include_caption_box": "",
   "when_you_tick_the_include_caption_box": "",
   "wide": "",
   "wide": "",
+  "will_lose_edit_access_on_date": "",
   "with_premium_subscription_you_also_get": "",
   "with_premium_subscription_you_also_get": "",
   "word_count": "",
   "word_count": "",
   "work_offline": "",
   "work_offline": "",
@@ -1627,6 +1639,7 @@
   "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
   "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
   "you_can_now_enable_sso": "",
   "you_can_now_enable_sso": "",
   "you_can_now_log_in_sso": "",
   "you_can_now_log_in_sso": "",
+  "you_can_only_add_n_people_to_edit_a_project": "",
   "you_can_request_a_maximum_of_limit_fixes_per_day": "",
   "you_can_request_a_maximum_of_limit_fixes_per_day": "",
   "you_cant_add_or_change_password_due_to_sso": "",
   "you_cant_add_or_change_password_due_to_sso": "",
   "you_cant_join_this_group_subscription": "",
   "you_cant_join_this_group_subscription": "",
@@ -1657,6 +1670,7 @@
   "your_password_was_detected": "",
   "your_password_was_detected": "",
   "your_plan": "",
   "your_plan": "",
   "your_plan_is_changing_at_term_end": "",
   "your_plan_is_changing_at_term_end": "",
+  "your_plan_is_limited_to_n_editors": "",
   "your_project_exceeded_compile_timeout_limit_on_free_plan": "",
   "your_project_exceeded_compile_timeout_limit_on_free_plan": "",
   "your_project_near_compile_timeout_limit": "",
   "your_project_near_compile_timeout_limit": "",
   "your_projects": "",
   "your_projects": "",

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

@@ -3,7 +3,9 @@ import { useOnlineUsersContext } from '@/features/ide-react/context/online-users
 import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
 import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
 import * as eventTracking from '@/infrastructure/event-tracking'
 import * as eventTracking from '@/infrastructure/event-tracking'
 import EditorNavigationToolbarRoot from '@/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root'
 import EditorNavigationToolbarRoot from '@/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root'
+import NewShareProjectModal from '@/features/share-project-modal/components/restricted-link-sharing/share-project-modal'
 import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal'
 import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal'
+import getMeta from '@/utils/meta'
 
 
 function EditorNavigationToolbar() {
 function EditorNavigationToolbar() {
   const [showShareModal, setShowShareModal] = useState(false)
   const [showShareModal, setShowShareModal] = useState(false)
@@ -19,6 +21,8 @@ function EditorNavigationToolbar() {
     setShowShareModal(false)
     setShowShareModal(false)
   }, [])
   }, [])
 
 
+  const showNewShareModal = getMeta('ol-linkSharingWarning')
+
   return (
   return (
     <>
     <>
       <EditorNavigationToolbarRoot
       <EditorNavigationToolbarRoot
@@ -26,10 +30,17 @@ function EditorNavigationToolbar() {
         openDoc={openDoc}
         openDoc={openDoc}
         openShareProjectModal={handleOpenShareModal}
         openShareProjectModal={handleOpenShareModal}
       />
       />
-      <ShareProjectModal
-        show={showShareModal}
-        handleHide={handleHideShareModal}
-      />
+      {showNewShareModal ? (
+        <NewShareProjectModal
+          show={showShareModal}
+          handleHide={handleHideShareModal}
+        />
+      ) : (
+        <ShareProjectModal
+          show={showShareModal}
+          handleHide={handleHideShareModal}
+        />
+      )}
     </>
     </>
   )
   )
 }
 }

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

@@ -0,0 +1,36 @@
+import { useTranslation } from 'react-i18next'
+import { Button } from 'react-bootstrap'
+import Notification from '@/shared/components/notification'
+import { upgradePlan } from '../../../../main/account-upgrade'
+
+export default function AddCollaboratorsUpgrade() {
+  const { t } = useTranslation()
+
+  return (
+    <div className="add-collaborators-upgrade">
+      <Notification
+        type="warning"
+        title={t('editor_limit_exceeded_in_this_project')}
+        content={<p>{t('your_plan_is_limited_to_n_editors')}</p>}
+        action={
+          <div className="upgrade-actions">
+            <Button
+              bsSize="sm"
+              className="btn-secondary"
+              onClick={() => upgradePlan('project-sharing')}
+            >
+              {t('upgrade')}
+            </Button>
+            <a
+              href="https://www.overleaf.com/blog/changes-to-project-sharing"
+              target="_blank"
+              rel="noreferrer"
+            >
+              {t('read_more')}
+            </a>
+          </div>
+        }
+      />
+    </div>
+  )
+}

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

@@ -0,0 +1,174 @@
+import { useState, useMemo, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Col, Form, Button } from 'react-bootstrap'
+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'
+
+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
+
+  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 (
+    <Form className="add-collabs">
+      <Col xs={10}>
+        <SelectCollaborators
+          loading={!nonMemberContacts}
+          options={nonMemberContacts || []}
+          placeholder="Email, comma separated"
+          multipleSelectionProps={multipleSelectionProps}
+          privileges={privileges}
+          setPrivileges={setPrivileges}
+          readOnly={readOnly}
+        />
+      </Col>
+
+      <Col xs={2}>
+        <div>
+          <span>&nbsp;&nbsp;&nbsp;&nbsp;</span>
+          <ClickableElementEnhancer
+            as={Button}
+            onClick={handleSubmit}
+            bsStyle="primary"
+          >
+            {t('invite')}
+          </ClickableElementEnhancer>
+        </div>
+      </Col>
+    </Form>
+  )
+}
+
+AddCollaborators.propTypes = {
+  readOnly: PropTypes.bool,
+}

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

@@ -0,0 +1,28 @@
+import { Button } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import Notification from '@/shared/components/notification'
+import { upgradePlan } from '@/main/account-upgrade'
+
+export default function CollaboratorsLimitUpgrade() {
+  const { t } = useTranslation()
+
+  return (
+    <div>
+      <Notification
+        type="info"
+        customIcon={<div />}
+        title={t('upgrade_to_add_more_editors')}
+        content={<p>{t('you_can_only_add_n_people_to_edit_a_project')}</p>}
+        action={
+          <Button
+            bsSize="sm"
+            className="btn-secondary"
+            onClick={() => upgradePlan('project-sharing')}
+          >
+            {t('upgrade')}
+          </Button>
+        }
+      />
+    </div>
+  )
+}

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

@@ -0,0 +1,242 @@
+import { useState, useEffect, useMemo, MouseEventHandler } 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 { Button, Col, Form, FormGroup } from 'react-bootstrap'
+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'
+
+type PermissionsOption = PermissionsLevel | 'removeAccess'
+
+type EditMemberProps = {
+  member: ProjectContextMember
+  hasExceededCollaboratorLimit: boolean
+  canAddCollaborators: boolean
+}
+
+type Privilege = {
+  key: PermissionsOption
+  label: string
+}
+
+export default function EditMember({
+  member,
+  hasExceededCollaboratorLimit,
+  canAddCollaborators,
+}: EditMemberProps) {
+  const [privileges, setPrivileges] = useState<PermissionsOption>(
+    member.privileges
+  )
+  const [confirmingOwnershipTransfer, setConfirmingOwnershipTransfer] =
+    useState(false)
+  const [privilegeChangePending, setPrivilegeChangePending] = useState(false)
+  const { t } = useTranslation()
+
+  // 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, invites } = useProjectContext()
+
+  // Immediately commit this change if it's lower impact (eg. editor > viewer)
+  // but show a confirmation button for removing access
+  function handlePrivilegeChange(newPrivileges: PermissionsOption) {
+    setPrivileges(newPrivileges)
+    if (newPrivileges !== 'removeAccess') {
+      commitPrivilegeChange(newPrivileges)
+    } else {
+      setPrivilegeChangePending(true)
+    }
+  }
+
+  function shouldWarnMember() {
+    return hasExceededCollaboratorLimit && privileges === 'readAndWrite'
+  }
+
+  function commitPrivilegeChange(newPrivileges: PermissionsOption) {
+    setPrivileges(newPrivileges)
+    setPrivilegeChangePending(false)
+
+    if (newPrivileges === 'owner') {
+      setConfirmingOwnershipTransfer(true)
+    } else if (newPrivileges === 'removeAccess') {
+      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,
+          })
+        }
+      )
+    } else if (
+      newPrivileges === 'readAndWrite' ||
+      newPrivileges === 'readOnly'
+    ) {
+      monitorRequest(() =>
+        updateMember(projectId, member, {
+          privilegeLevel: newPrivileges,
+        })
+      ).then(() => {
+        updateProject({
+          members: members.map(item =>
+            item._id === member._id ? { ...item, newPrivileges } : item
+          ),
+        })
+      })
+    }
+  }
+
+  if (confirmingOwnershipTransfer) {
+    return (
+      <TransferOwnershipModal
+        member={member}
+        cancel={() => {
+          setConfirmingOwnershipTransfer(false)
+          setPrivileges(member.privileges)
+        }}
+      />
+    )
+  }
+
+  return (
+    <Form
+      horizontal
+      id="share-project-form"
+      onSubmit={e => {
+        e.preventDefault()
+        commitPrivilegeChange(privileges)
+      }}
+    >
+      <FormGroup className="project-member">
+        <Col xs={7}>
+          <div className="project-member-email-icon">
+            <Icon type={shouldWarnMember() ? 'warning' : 'user'} fw />
+            <div className="email-warning">
+              {member.email}
+              {shouldWarnMember() && (
+                <div className="subtitle">
+                  {t('will_lose_edit_access_on_date', {
+                    date: '[date]',
+                  })}
+                </div>
+              )}
+            </div>
+          </div>
+        </Col>
+
+        <Col xs={2}>
+          {privileges !== member.privileges && privilegeChangePending && (
+            <ChangePrivilegesActions
+              handleReset={() => setPrivileges(member.privileges)}
+            />
+          )}
+        </Col>
+
+        <Col xs={3}>
+          <SelectPrivilege
+            value={privileges}
+            handleChange={value => {
+              value && handlePrivilegeChange(value.key)
+            }}
+            canAddCollaborators={canAddCollaborators}
+          />
+        </Col>
+      </FormGroup>
+    </Form>
+  )
+}
+EditMember.propTypes = {
+  member: PropTypes.shape({
+    _id: PropTypes.string.isRequired,
+    email: PropTypes.string.isRequired,
+    privileges: PropTypes.string.isRequired,
+  }),
+  hasExceededCollaboratorLimit: PropTypes.bool.isRequired,
+  canAddCollaborators: PropTypes.bool.isRequired,
+}
+
+type SelectPrivilegeProps = {
+  value: string
+  handleChange: (item: Privilege | null | undefined) => void
+  canAddCollaborators: boolean
+}
+
+function SelectPrivilege({
+  value,
+  handleChange,
+  canAddCollaborators,
+}: SelectPrivilegeProps) {
+  const { t } = useTranslation()
+
+  const privileges = useMemo(
+    (): Privilege[] => [
+      { key: 'owner', label: t('make_owner') },
+      { key: 'readAndWrite', label: t('editor') },
+      { key: 'readOnly', label: t('viewer') },
+      { key: 'removeAccess', label: t('remove_access') },
+    ],
+    [t]
+  )
+
+  function getPrivilegeSubtitle(privilege: PermissionsOption) {
+    return !canAddCollaborators &&
+      privilege === 'readAndWrite' &&
+      value !== 'readAndWrite'
+      ? t('limited_to_n_editors_per_project')
+      : ''
+  }
+
+  return (
+    <Select
+      items={privileges}
+      itemToKey={item => item.key}
+      itemToString={item => (item ? item.label : '')}
+      itemToSubtitle={item => (item ? getPrivilegeSubtitle(item.key) : '')}
+      itemToDisabled={item =>
+        item ? getPrivilegeSubtitle(item.key) !== '' : false
+      }
+      defaultItem={privileges.find(item => item.key === value)}
+      selected={privileges.find(item => item.key === value)}
+      name="privileges"
+      onSelectedItemChanged={handleChange}
+      selectedIcon
+    />
+  )
+}
+
+type ChangePrivilegesActionsProps = {
+  handleReset: MouseEventHandler<Button>
+}
+function ChangePrivilegesActions({
+  handleReset,
+}: ChangePrivilegesActionsProps) {
+  const { t } = useTranslation()
+
+  return (
+    <div className="text-center">
+      <Button type="submit" bsSize="sm" bsStyle="primary">
+        {t('change_or_cancel-change')}
+      </Button>
+      <div className="text-sm">
+        {t('change_or_cancel-or')}
+        &nbsp;
+        <Button type="button" className="btn-inline-link" onClick={handleReset}>
+          {t('change_or_cancel-cancel')}
+        </Button>
+      </div>
+    </div>
+  )
+}

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

@@ -0,0 +1,121 @@
+import { useCallback } from 'react'
+import PropTypes from 'prop-types'
+import { useShareProjectContext } from './share-project-modal'
+import Icon from '@/shared/components/icon'
+import { Button, Col, Row } from 'react-bootstrap'
+import Tooltip from '@/shared/components/tooltip'
+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'
+
+export default function Invite({ invite, isProjectOwner }) {
+  const { t } = useTranslation()
+  return (
+    <Row className="project-invite">
+      <Col xs={8}>
+        <div>{invite.email}</div>
+        <div className="small">
+          {t('invite_not_accepted')}
+          .&nbsp;
+          {isProjectOwner && <ResendInvite invite={invite} />}
+        </div>
+      </Col>
+
+      <Col xs={3} className="text-right">
+        <MemberPrivileges privileges={invite.privileges} />
+      </Col>
+
+      {isProjectOwner && (
+        <Col xs={1} className="text-center">
+          <RevokeInvite invite={invite} />
+        </Col>
+      )}
+    </Row>
+  )
+}
+
+Invite.propTypes = {
+  invite: PropTypes.object.isRequired,
+  isProjectOwner: PropTypes.bool.isRequired,
+}
+
+function ResendInvite({ invite }) {
+  const { t } = useTranslation()
+  const { monitorRequest } = useShareProjectContext()
+  const { _id: projectId } = useProjectContext()
+
+  // const buttonRef = useRef(null)
+  //
+  const handleClick = useCallback(
+    () =>
+      monitorRequest(() => resendInvite(projectId, invite)).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]
+  )
+
+  return (
+    <Button
+      bsStyle="link"
+      className="btn-inline-link"
+      onClick={handleClick}
+      // ref={buttonRef}
+    >
+      {t('resend')}
+    </Button>
+  )
+}
+
+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 (
+    <Tooltip
+      id="revoke-invite"
+      description={t('revoke_invite')}
+      overlayProps={{ placement: 'bottom' }}
+    >
+      <Button
+        type="button"
+        bsStyle="link"
+        onClick={handleClick}
+        aria-label={t('revoke')}
+        className="btn-inline-link"
+      >
+        <Icon type="times" />
+      </Button>
+    </Tooltip>
+  )
+}
+
+RevokeInvite.propTypes = {
+  invite: PropTypes.object.isRequired,
+}

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

@@ -0,0 +1,311 @@
+import { useCallback, useState, useEffect } from 'react'
+import PropTypes from 'prop-types'
+import { Button, Col, Row } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import Tooltip from '@/shared/components/tooltip'
+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'
+
+export default function LinkSharing() {
+  const [inflight, setInflight] = useState(false)
+  const [showLinks, setShowLinks] = useState(false)
+
+  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}
+        />
+      )
+
+    // 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 }) {
+  const { t } = useTranslation()
+  return (
+    <Row className="public-access-level">
+      <Col xs={12} className="text-center">
+        <strong>{t('link_sharing_is_off_short')}</strong>
+        <span>&nbsp;&nbsp;</span>
+        <Button
+          type="button"
+          bsStyle="link"
+          className="btn-inline-link"
+          onClick={() => {
+            setAccessLevel('tokenBased')
+            eventTracking.sendMB('link-sharing-click', { projectId })
+          }}
+          disabled={inflight}
+        >
+          {t('turn_on_link_sharing')}
+        </Button>
+        <span>&nbsp;&nbsp;</span>
+        <LinkSharingInfo />
+      </Col>
+    </Row>
+  )
+}
+
+PrivateSharing.propTypes = {
+  setAccessLevel: PropTypes.func.isRequired,
+  inflight: PropTypes.bool,
+  projectId: PropTypes.string,
+}
+
+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 (
+    <Row className="public-access-level">
+      <Col xs={12} className="text-center">
+        <strong>{t('link_sharing_is_on')}</strong>
+        <span>&nbsp;&nbsp;</span>
+        <Button
+          bsStyle="link"
+          className="btn-inline-link"
+          onClick={() => setAccessLevel('private')}
+          disabled={inflight}
+        >
+          {t('turn_off_link_sharing')}
+        </Button>
+        <span>&nbsp;&nbsp;</span>
+        <LinkSharingInfo />
+        <Button
+          bsStyle="link"
+          className="btn-chevron"
+          onClick={() => setShowLinks(!showLinks)}
+        >
+          <Icon type={showLinks ? 'chevron-up' : 'chevron-down'} fw />
+        </Button>
+      </Col>
+      {showLinks && (
+        <>
+          <Col 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>
+          </Col>
+        </>
+      )}
+    </Row>
+  )
+}
+
+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 (
+    <Row className="public-access-level">
+      <Col 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>
+        <Button
+          type="button"
+          bsStyle="link"
+          className="btn-inline-link"
+          onClick={() => setAccessLevel('private')}
+          disabled={inflight}
+        >
+          {t('make_private')}
+        </Button>
+        <span>&nbsp;&nbsp;</span>
+        <LinkSharingInfo />
+      </Col>
+    </Row>
+  )
+}
+
+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 (
+    <Row className="public-access-level">
+      <Col xs={12} 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>
+      </Col>
+    </Row>
+  )
+}
+
+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 = window.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 (
+    <Tooltip
+      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"
+      >
+        <Icon type="question-circle" />
+      </a>
+    </Tooltip>
+  )
+}

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

@@ -0,0 +1,20 @@
+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')
+
+    default:
+      return null
+  }
+}
+MemberPrivileges.propTypes = {
+  privileges: PropTypes.string.isRequired,
+}

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

@@ -0,0 +1,23 @@
+import { useProjectContext } from '@/shared/context/project-context'
+import { Col, Row } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import Icon from '@/shared/components/icon'
+
+export default function OwnerInfo() {
+  const { t } = useTranslation()
+  const { owner } = useProjectContext()
+
+  return (
+    <Row className="project-member">
+      <Col xs={9}>
+        <div className="project-member-email-icon">
+          <Icon type="user" fw />
+          <div className="email-warning">{owner?.email}</div>
+        </div>
+      </Col>
+      <Col xs={3} className="text-left">
+        {t('owner')}
+      </Col>
+    </Row>
+  )
+}

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

@@ -0,0 +1,357 @@
+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 { FormControl } from 'react-bootstrap'
+
+import Icon from '@/shared/components/icon'
+
+// 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,
+  privileges,
+  setPrivileges,
+  readOnly,
+}) {
+  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])
+
+  const {
+    isOpen,
+    getLabelProps,
+    getMenuProps,
+    getInputProps,
+    getComboboxProps,
+    highlightedIndex,
+    getItemProps,
+    reset,
+  } = useCombobox({
+    inputValue,
+    defaultHighlightedIndex: 0,
+    items: filteredOptions,
+    itemToString: item => item && item.name,
+    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 (readOnly) {
+      setPrivileges('readOnly')
+    }
+    if (isOpen && filteredOptions.length === 0) {
+      reset()
+    }
+  }, [reset, isOpen, filteredOptions.length, readOnly, setPrivileges])
+
+  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 && <Icon type="refresh" spin />}
+      </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">
+          <div className="tags-main">
+            {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: true,
+                    '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>
+          <FormControl
+            componentClass="select"
+            className="privileges"
+            bsSize="sm"
+            value={privileges}
+            onChange={event => setPrivileges(event.target.value)}
+          >
+            {!readOnly && <option value="readAndWrite">{t('can_edit')}</option>}
+            <option value="readOnly">{t('read_only')}</option>
+          </FormControl>
+        </div>
+
+        <div className={classnames({ autocomplete: isOpen })}>
+          <ul {...getMenuProps()} className="suggestion-list">
+            {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,
+  privileges: PropTypes.string.isRequired,
+  setPrivileges: PropTypes.func.isRequired,
+  readOnly: PropTypes.bool.isRequired,
+}
+
+function Option({ selected, item, getItemProps, index }) {
+  return (
+    <li
+      className={classnames('suggestion-item', { selected })}
+      {...getItemProps({ item, index })}
+    >
+      <Icon type="user" fw />
+      &nbsp;
+      {item.display}
+    </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 (
+    <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>
+  )
+}
+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,
+}

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

@@ -0,0 +1,33 @@
+import { Col, Row } from 'react-bootstrap'
+import PropTypes from 'prop-types'
+import { useTranslation } from 'react-i18next'
+import { useProjectContext } from '@/shared/context/project-context'
+
+export default function SendInvitesNotice() {
+  const { publicAccessLevel } = useProjectContext()
+
+  return (
+    <Row className="public-access-level public-access-level--notice">
+      <Col xs={12} className="text-center">
+        <AccessLevel level={publicAccessLevel} />
+      </Col>
+    </Row>
+  )
+}
+
+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,
+}

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

@@ -0,0 +1,27 @@
+import { Row } from 'react-bootstrap'
+import AddCollaborators from './add-collaborators'
+import AddCollaboratorsUpgrade from './add-collaborators-upgrade'
+import CollaboratorsLimitUpgrade from './collaborators-limit-upgrade'
+import PropTypes from 'prop-types'
+
+export default function SendInvites({
+  canAddCollaborators,
+  hasExceededCollaboratorLimit,
+}) {
+  return (
+    <Row className="invite-controls">
+      {hasExceededCollaboratorLimit && <AddCollaboratorsUpgrade />}
+      {!canAddCollaborators && !hasExceededCollaboratorLimit && (
+        <CollaboratorsLimitUpgrade />
+      )}
+      {!hasExceededCollaboratorLimit && (
+        <AddCollaborators readOnly={!canAddCollaborators} />
+      )}
+    </Row>
+  )
+}
+
+SendInvites.propTypes = {
+  canAddCollaborators: PropTypes.bool,
+  hasExceededCollaboratorLimit: PropTypes.bool,
+}

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

@@ -0,0 +1,90 @@
+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
+    }
+
+    return (
+      members.filter(member => member.privileges === 'readAndWrite').length +
+        invites.length <
+      (features.collaborators ?? 1)
+    )
+  }, [members, invites, features, isProjectOwner])
+
+  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])
+
+  return (
+    <>
+      {isProjectOwner ? (
+        <SendInvites
+          canAddCollaborators={canAddCollaborators}
+          hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
+        />
+      ) : (
+        <SendInvitesNotice />
+      )}
+      {isProjectOwner && <LinkSharing />}
+
+      <OwnerInfo />
+
+      {members.map(member =>
+        isProjectOwner ? (
+          <EditMember
+            key={member._id}
+            member={member}
+            hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
+            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 />
+      )}
+    </>
+  )
+}

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

@@ -0,0 +1,104 @@
+import { Button, Modal, Grid } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import Icon from '@/shared/components/icon'
+import AccessibleModal from '@/shared/components/accessible-modal'
+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'
+
+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 (
+    <AccessibleModal show={show} onHide={cancel} animation={animation}>
+      <Modal.Header closeButton>
+        <Modal.Title>{t('share_project')}</Modal.Title>
+      </Modal.Header>
+
+      <Modal.Body className="modal-body-share">
+        <Grid fluid>
+          <Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
+            {isRestrictedTokenMember ? (
+              <ReadOnlyTokenLink />
+            ) : (
+              <ShareModalBody />
+            )}
+          </Suspense>
+        </Grid>
+      </Modal.Body>
+
+      <Modal.Footer className="modal-footer-share">
+        <div className="modal-footer-left">
+          {inFlight && <Icon type="refresh" spin />}
+          {error && (
+            <span className="text-danger error">
+              <ErrorMessage error={error} />
+            </span>
+          )}
+        </div>
+
+        <div className="modal-footer-right">
+          <ClickableElementEnhancer
+            onClick={cancel}
+            as={Button}
+            type="button"
+            bsStyle={null}
+            className="btn-secondary"
+            disabled={inFlight}
+          >
+            {t('close')}
+          </ClickableElementEnhancer>
+        </div>
+      </Modal.Footer>
+    </AccessibleModal>
+  )
+}
+
+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')}</>
+
+    default:
+      return <>{t('generic_something_went_wrong')}</>
+  }
+}

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

@@ -0,0 +1,140 @@
+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'
+
+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 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
+  animation?: boolean
+}
+
+const ShareProjectModal = React.memo(function ShareProjectModal({
+  handleHide,
+  show,
+  animation = true,
+}: ShareProjectModalProps) {
+  const [inFlight, setInFlight] =
+    useState<ShareProjectContextValue['inFlight']>(false)
+  const [error, setError] = useState<ShareProjectContextValue['error']>()
+
+  const project = useProjectContext()
+
+  const { splitTestVariants } = useSplitTestContext()
+
+  // 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

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

@@ -0,0 +1,86 @@
+import { useState } from 'react'
+import { Modal, Button } from 'react-bootstrap'
+import { Trans, useTranslation } from 'react-i18next'
+import PropTypes from 'prop-types'
+import Icon from '@/shared/components/icon'
+import { transferProjectOwnership } from '../../utils/api'
+import AccessibleModal from '@/shared/components/accessible-modal'
+import { useProjectContext } from '@/shared/context/project-context'
+import { useLocation } from '@/shared/hooks/use-location'
+
+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 (
+    <AccessibleModal show onHide={cancel}>
+      <Modal.Header closeButton>
+        <Modal.Title>{t('change_project_owner')}</Modal.Title>
+      </Modal.Header>
+      <Modal.Body>
+        <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>
+      </Modal.Body>
+      <Modal.Footer>
+        <div className="modal-footer-left">
+          {inflight && <Icon type="refresh" spin />}
+          {error && (
+            <span className="text-danger">
+              {t('generic_something_went_wrong')}
+            </span>
+          )}
+        </div>
+        <div className="modal-footer-right">
+          <Button
+            type="button"
+            bsStyle={null}
+            className="btn-secondary"
+            onClick={cancel}
+            disabled={inflight}
+          >
+            {t('cancel')}
+          </Button>
+          <Button
+            type="button"
+            bsStyle="primary"
+            onClick={confirm}
+            disabled={inflight}
+          >
+            {t('change_owner')}
+          </Button>
+        </div>
+      </Modal.Footer>
+    </AccessibleModal>
+  )
+}
+TransferOwnershipModal.propTypes = {
+  member: PropTypes.object.isRequired,
+  cancel: PropTypes.func.isRequired,
+}

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

@@ -0,0 +1,22 @@
+import PropTypes from 'prop-types'
+import { Col, Row } from 'react-bootstrap'
+import MemberPrivileges from './member-privileges'
+
+export default function ViewMember({ member }) {
+  return (
+    <Row className="project-member">
+      <Col xs={7}>{member.email}</Col>
+      <Col xs={3}>
+        <MemberPrivileges privileges={member.privileges} />
+      </Col>
+    </Row>
+  )
+}
+
+ViewMember.propTypes = {
+  member: PropTypes.shape({
+    _id: PropTypes.string.isRequired,
+    email: PropTypes.string.isRequired,
+    privileges: PropTypes.string.isRequired,
+  }).isRequired,
+}

+ 52 - 17
services/web/frontend/js/shared/components/select.tsx

@@ -6,6 +6,7 @@ import {
   KeyboardEventHandler,
   KeyboardEventHandler,
   useCallback,
   useCallback,
   ReactNode,
   ReactNode,
+  useState,
 } from 'react'
 } from 'react'
 import classNames from 'classnames'
 import classNames from 'classnames'
 import { useSelect } from 'downshift'
 import { useSelect } from 'downshift'
@@ -34,12 +35,18 @@ export type SelectProps<T> = {
   itemToKey: (item: T) => string
   itemToKey: (item: T) => string
   // Callback invoked after the selected item is updated.
   // Callback invoked after the selected item is updated.
   onSelectedItemChanged?: (item: T | null | undefined) => void
   onSelectedItemChanged?: (item: T | null | undefined) => void
+  // Optionally directly control the selected item.
+  selected?: T
   // When `true` item selection is disabled.
   // When `true` item selection is disabled.
   disabled?: boolean
   disabled?: boolean
+  // Determine which items should be disabled
+  itemToDisabled?: (item: T | null | undefined) => boolean
   // When `true` displays an "Optional" subtext after the `label` caption.
   // When `true` displays an "Optional" subtext after the `label` caption.
   optionalLabel?: boolean
   optionalLabel?: boolean
   // When `true` displays a spinner next to the `label` caption.
   // When `true` displays a spinner next to the `label` caption.
   loading?: boolean
   loading?: boolean
+  // Show a checkmark next to the selected item
+  selectedIcon?: boolean
 }
 }
 
 
 export const Select = <T,>({
 export const Select = <T,>({
@@ -52,14 +59,20 @@ export const Select = <T,>({
   itemToSubtitle,
   itemToSubtitle,
   itemToKey,
   itemToKey,
   onSelectedItemChanged,
   onSelectedItemChanged,
+  selected,
   disabled = false,
   disabled = false,
+  itemToDisabled,
   optionalLabel = false,
   optionalLabel = false,
   loading = false,
   loading = false,
+  selectedIcon = false,
 }: SelectProps<T>) => {
 }: SelectProps<T>) => {
+  const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
+    defaultItem
+  )
+
   const { t } = useTranslation()
   const { t } = useTranslation()
   const {
   const {
     isOpen,
     isOpen,
-    selectedItem,
     getToggleButtonProps,
     getToggleButtonProps,
     getLabelProps,
     getLabelProps,
     getMenuProps,
     getMenuProps,
@@ -69,13 +82,19 @@ export const Select = <T,>({
   } = useSelect({
   } = useSelect({
     items: items ?? [],
     items: items ?? [],
     itemToString,
     itemToString,
+    selectedItem: selected || defaultItem,
     onSelectedItemChange: changes => {
     onSelectedItemChange: changes => {
       if (onSelectedItemChanged) {
       if (onSelectedItemChanged) {
         onSelectedItemChanged(changes.selectedItem)
         onSelectedItemChanged(changes.selectedItem)
       }
       }
+      setSelectedItem(changes.selectedItem)
     },
     },
   })
   })
 
 
+  useEffect(() => {
+    setSelectedItem(selected)
+  }, [selected])
+
   const rootRef = useRef<HTMLDivElement | null>(null)
   const rootRef = useRef<HTMLDivElement | null>(null)
   useEffect(() => {
   useEffect(() => {
     if (!name || !rootRef.current) return
     if (!name || !rootRef.current) return
@@ -153,23 +172,39 @@ export const Select = <T,>({
         {...getMenuProps({ disabled })}
         {...getMenuProps({ disabled })}
       >
       >
         {isOpen &&
         {isOpen &&
-          items?.map((item, index) => (
-            <li
-              className={classNames({
-                'select-highlighted': highlightedIndex === index,
-                'selected-active': selectedItem === item,
-              })}
-              key={itemToKey(item)}
-              {...getItemProps({ item, index })}
-            >
-              <span className="select-item-title">{itemToString(item)}</span>
-              {itemToSubtitle ? (
-                <span className="text-muted select-item-subtitle">
-                  {itemToSubtitle(item)}
+          items?.map((item, index) => {
+            const isDisabled = itemToDisabled && itemToDisabled(item)
+            return (
+              <li
+                className={classNames({
+                  'select-highlighted': highlightedIndex === index,
+                  'selected-active': selectedItem === item,
+                  'select-icon': selectedIcon,
+                  'select-disabled': isDisabled,
+                })}
+                key={itemToKey(item)}
+                {...getItemProps({ item, index, disabled: isDisabled })}
+              >
+                <span className="select-item-title">
+                  {selectedIcon && (
+                    <div className="select-item-icon">
+                      {(selectedItem === item ||
+                        (!selectedItem && defaultItem === item)) && (
+                        <Icon type="check" fw />
+                      )}
+                    </div>
+                  )}
+                  {itemToString(item)}
                 </span>
                 </span>
-              ) : null}
-            </li>
-          ))}
+
+                {itemToSubtitle ? (
+                  <span className="text-muted select-item-subtitle">
+                    {itemToSubtitle(item)}
+                  </span>
+                ) : null}
+              </li>
+            )
+          })}
       </ul>
       </ul>
     </div>
     </div>
   )
   )

+ 2 - 38
services/web/frontend/js/shared/context/project-context.tsx

@@ -1,45 +1,9 @@
 import { FC, createContext, useContext, useMemo } from 'react'
 import { FC, createContext, useContext, useMemo } from 'react'
 import useScopeValue from '../hooks/use-scope-value'
 import useScopeValue from '../hooks/use-scope-value'
 import getMeta from '@/utils/meta'
 import getMeta from '@/utils/meta'
-import { UserId } from '../../../../types/user'
-import { PublicAccessLevel } from '../../../../types/public-access-level'
-import type * as ReviewPanel from '@/features/source-editor/context/review-panel/types/review-panel-state'
+import { ProjectContextValue } from './types/project-context'
 
 
-const ProjectContext = createContext<
-  | {
-      _id: string
-      name: string
-      rootDocId?: string
-      compiler: string
-      members: { _id: UserId; email: string; privileges: string }[]
-      invites: { _id: UserId }[]
-      features: {
-        collaborators?: number
-        compileGroup?: 'alpha' | 'standard' | 'priority'
-        trackChanges?: boolean
-        trackChangesVisible?: boolean
-        references?: boolean
-        mendeley?: boolean
-        zotero?: boolean
-        versioning?: boolean
-        gitBridge?: boolean
-        referencesSearch?: boolean
-        github?: boolean
-      }
-      publicAccessLevel?: PublicAccessLevel
-      owner: {
-        _id: UserId
-        email: string
-      }
-      tags: {
-        _id: string
-        name: string
-        color?: string
-      }[]
-      trackChangesState: ReviewPanel.Value<'trackChangesState'>
-    }
-  | undefined
->(undefined)
+const ProjectContext = createContext<ProjectContextValue | undefined>(undefined)
 
 
 export function useProjectContext() {
 export function useProjectContext() {
   const context = useContext(ProjectContext)
   const context = useContext(ProjectContext)

+ 44 - 0
services/web/frontend/js/shared/context/types/project-context.tsx

@@ -0,0 +1,44 @@
+import { UserId } from '../../../../../types/user'
+import { PublicAccessLevel } from '../../../../../types/public-access-level'
+import type * as ReviewPanel from '@/features/source-editor/context/review-panel/types/review-panel-state'
+
+export type ProjectContextMember = {
+  _id: UserId
+  privileges: 'readOnly' | 'readAndWrite'
+  email: string
+}
+
+export type ProjectContextValue = {
+  _id: string
+  name: string
+  rootDocId?: string
+  compiler: string
+  members: ProjectContextMember[]
+  invites: { _id: UserId }[]
+  features: {
+    collaborators?: number
+    compileGroup?: 'alpha' | 'standard' | 'priority'
+    trackChanges?: boolean
+    trackChangesVisible?: boolean
+    references?: boolean
+    mendeley?: boolean
+    zotero?: boolean
+    versioning?: boolean
+    gitBridge?: boolean
+    referencesSearch?: boolean
+    github?: boolean
+  }
+  publicAccessLevel?: PublicAccessLevel
+  owner: {
+    _id: UserId
+    email: string
+  }
+  tags: {
+    _id: string
+    name: string
+    color?: string
+  }[]
+  trackChangesState: ReviewPanel.Value<'trackChangesState'>
+}
+
+export type ProjectContextUpdateValue = Partial<ProjectContextValue>

+ 1 - 0
services/web/frontend/js/utils/meta.ts

@@ -123,6 +123,7 @@ export interface Meta {
   'ol-languages': SpellCheckLanguage[]
   'ol-languages': SpellCheckLanguage[]
   'ol-learnedWords': string[]
   'ol-learnedWords': string[]
   'ol-legacyEditorThemes': string[]
   'ol-legacyEditorThemes': string[]
+  'ol-linkSharingWarning': boolean
   'ol-loadingText': string
   'ol-loadingText': string
   'ol-managedGroupSubscriptions': ManagedGroupSubscription[]
   'ol-managedGroupSubscriptions': ManagedGroupSubscription[]
   'ol-managedInstitutions': ManagedInstitution[]
   'ol-managedInstitutions': ManagedInstitution[]

+ 26 - 0
services/web/frontend/stories/select.stories.tsx

@@ -29,6 +29,32 @@ export const WithSubtitles = () => {
   )
   )
 }
 }
 
 
+export const WithSelectedIcon = () => {
+  return (
+    <Select
+      items={items}
+      itemToString={x => String(x?.value)}
+      itemToKey={x => String(x.key)}
+      itemToSubtitle={x => x?.group ?? ''}
+      defaultText="Choose an item"
+      selectedIcon
+    />
+  )
+}
+
+export const WithDisabledItem = () => {
+  return (
+    <Select
+      items={items}
+      itemToString={x => String(x?.value)}
+      itemToKey={x => String(x.key)}
+      itemToDisabled={x => x?.key === 1}
+      itemToSubtitle={x => x?.group ?? ''}
+      defaultText="Choose an item"
+    />
+  )
+}
+
 export default {
 export default {
   title: 'Shared / Components / Select',
   title: 'Shared / Components / Select',
   component: Select,
   component: Select,

+ 67 - 7
services/web/frontend/stylesheets/app/editor/share.less

@@ -18,16 +18,21 @@
     font-size: inherit;
     font-size: inherit;
   }
   }
 
 
-  .project-member,
+  .project-member {
+    padding: (@line-height-computed / 2) 0;
+    font-size: 16px;
+    span {
+      padding-right: @line-height-computed / 2;
+    }
+  }
+
   .project-invite,
   .project-invite,
   .public-access-level {
   .public-access-level {
-    padding: (@line-height-computed / 2) 0;
-    border-bottom: 1px solid @gray-lighter;
     font-size: 14px;
     font-size: 14px;
   }
   }
 
 
   .public-access-level {
   .public-access-level {
-    padding-top: 0;
+    margin-top: @line-height-computed / 4;
     font-size: 13px;
     font-size: 13px;
     padding-bottom: @modal-inner-padding;
     padding-bottom: @modal-inner-padding;
     .access-token-display-area {
     .access-token-display-area {
@@ -45,6 +50,14 @@
         }
         }
       }
       }
     }
     }
+    .fa-chevron-down,
+    .fa-chevron-up {
+      vertical-align: top;
+      color: @neutral-70;
+    }
+    .btn-chevron {
+      padding: 0 (@line-height-computed / 2);
+    }
   }
   }
 
 
   .public-access-level.public-access-level--notice {
   .public-access-level.public-access-level--notice {
@@ -61,14 +74,50 @@
     }
     }
   }
   }
 
 
+  .project-member {
+    .select-trigger {
+      color: @neutral-70;
+      border: none;
+      padding: 0 10px 5px 10px;
+    }
+
+    .select-items {
+      max-height: 300px;
+      width: 250%;
+      li:last-child {
+        color: @brand-danger;
+      }
+    }
+
+    .project-member-email-icon {
+      display: grid;
+      grid-template-columns: 2em auto;
+      align-items: center;
+      padding-bottom: 5px;
+
+      .fa-warning {
+        color: @brand-warning;
+      }
+
+      .subtitle {
+        font-size: @font-size-small;
+      }
+    }
+  }
+
+  .project-member .text-left,
+  .project-invite .text-left {
+    padding-left: 25px;
+  }
+
   .invite-controls {
   .invite-controls {
     .small {
     .small {
       padding: 2px;
       padding: 2px;
       margin-bottom: 0;
       margin-bottom: 0;
     }
     }
-    padding: @line-height-computed / 2;
-    background-color: @gray-lightest;
-    margin-top: @line-height-computed / 2;
+    .add-collabs {
+      margin-top: @line-height-computed / 2;
+    }
     form {
     form {
       .form-group {
       .form-group {
         margin-bottom: @line-height-computed / 2;
         margin-bottom: @line-height-computed / 2;
@@ -79,14 +128,25 @@
       .privileges {
       .privileges {
         display: inline-block;
         display: inline-block;
         width: auto;
         width: auto;
+      }
+      .tags-new .privileges {
+        background: transparent;
+        width: auto;
         height: 30px;
         height: 30px;
         font-size: 14px;
         font-size: 14px;
+        border: none;
+        border-right: 5px solid transparent;
       }
       }
     }
     }
     .add-collaborators-upgrade {
     .add-collaborators-upgrade {
       display: flex;
       display: flex;
       flex-direction: column;
       flex-direction: column;
       align-items: center;
       align-items: center;
+
+      .upgrade-actions {
+        display: flex;
+        gap: @margin-md;
+      }
     }
     }
   }
   }
 
 

+ 18 - 2
services/web/frontend/stylesheets/components/select.less

@@ -14,7 +14,7 @@
   }
   }
 }
 }
 
 
-.select-highlighted {
+.select-highlighted:not(.select-disabled) {
   background-color: @neutral-10;
   background-color: @neutral-10;
   border-radius: 4px;
   border-radius: 4px;
 }
 }
@@ -23,6 +23,11 @@
   font-weight: bold;
   font-weight: bold;
 }
 }
 
 
+.select-disabled {
+  color: @text-muted;
+  cursor: not-allowed;
+}
+
 .select-items {
 .select-items {
   list-style: none;
   list-style: none;
   width: 100%;
   width: 100%;
@@ -46,14 +51,25 @@
 .select-item-title,
 .select-item-title,
 .select-item-subtitle {
 .select-item-subtitle {
   display: block;
   display: block;
-  cursor: default;
   user-select: none;
   user-select: none;
+  :not(.select-disabled) {
+    cursor: default;
+  }
+}
+
+.select-item-icon {
+  width: 30px;
+  display: inline-block;
 }
 }
 
 
 .select-item-subtitle {
 .select-item-subtitle {
   font-size: 0.9rem;
   font-size: 0.9rem;
 }
 }
 
 
+.select-icon .select-item-subtitle {
+  margin-left: 30px;
+}
+
 .select-optional-label {
 .select-optional-label {
   font-weight: normal;
   font-weight: normal;
 }
 }

+ 4 - 1
services/web/frontend/stylesheets/components/tags-input.less

@@ -21,15 +21,18 @@
 
 
 .tags-input .tags {
 .tags-input .tags {
   .form-control;
   .form-control;
+  appearance: textfield;
   -moz-appearance: textfield;
   -moz-appearance: textfield;
   -webkit-appearance: textfield;
   -webkit-appearance: textfield;
+  padding: 0 0 0 5px;
   overflow: hidden;
   overflow: hidden;
   word-wrap: break-word;
   word-wrap: break-word;
   cursor: text;
   cursor: text;
   background-color: #fff;
   background-color: #fff;
   height: 100%;
   height: 100%;
   display: flex;
   display: flex;
-  flex-wrap: wrap;
+  justify-content: space-between;
+  flex-direction: row;
 }
 }
 .tags-input .tags:focus-within {
 .tags-input .tags:focus-within {
   &:extend(.input-focus-style);
   &:extend(.input-focus-style);

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

@@ -71,6 +71,7 @@
   "add_more_members": "Add more members",
   "add_more_members": "Add more members",
   "add_new_email": "Add new email",
   "add_new_email": "Add new email",
   "add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
   "add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
+  "add_people": "Add people",
   "add_role_and_department": "Add role and department",
   "add_role_and_department": "Add role and department",
   "add_to_tag": "Add to tag",
   "add_to_tag": "Add to tag",
   "add_your_comment_here": "Add your comment here",
   "add_your_comment_here": "Add your comment here",
@@ -501,8 +502,10 @@
   "editing": "Editing",
   "editing": "Editing",
   "editing_and_collaboration": "Editing and collaboration",
   "editing_and_collaboration": "Editing and collaboration",
   "editing_captions": "Editing captions",
   "editing_captions": "Editing captions",
+  "editor": "Editor",
   "editor_and_pdf": "Editor & PDF",
   "editor_and_pdf": "Editor & PDF",
   "editor_disconected_click_to_reconnect": "Editor disconnected, click anywhere to reconnect.",
   "editor_disconected_click_to_reconnect": "Editor disconnected, click anywhere to reconnect.",
+  "editor_limit_exceeded_in_this_project": "Too many editors in this project",
   "editor_only_hide_pdf": "Editor only <0>(hide PDF)</0>",
   "editor_only_hide_pdf": "Editor only <0>(hide PDF)</0>",
   "editor_resources": "Editor Resources",
   "editor_resources": "Editor Resources",
   "editor_theme": "Editor theme",
   "editor_theme": "Editor theme",
@@ -939,6 +942,7 @@
   "invalid_password_too_similar": "Password is too similar to parts of email address",
   "invalid_password_too_similar": "Password is too similar to parts of email address",
   "invalid_request": "Invalid Request. Please correct the data and try again.",
   "invalid_request": "Invalid Request. Please correct the data and try again.",
   "invalid_zip_file": "Invalid zip file",
   "invalid_zip_file": "Invalid zip file",
+  "invite": "Invite",
   "invite_more_collabs": "Invite more collaborators",
   "invite_more_collabs": "Invite more collaborators",
   "invite_not_accepted": "Invite not yet accepted",
   "invite_not_accepted": "Invite not yet accepted",
   "invite_not_valid": "This is not a valid project invite",
   "invite_not_valid": "This is not a valid project invite",
@@ -1034,6 +1038,7 @@
   "license": "License",
   "license": "License",
   "license_for_educational_purposes": "This license is for educational purposes (applies to students or faculty using __appName__ for teaching)",
   "license_for_educational_purposes": "This license is for educational purposes (applies to students or faculty using __appName__ for teaching)",
   "limited_offer": "Limited offer",
   "limited_offer": "Limited offer",
+  "limited_to_n_editors_per_project": "Limited to n editors per project",
   "line_height": "Line Height",
   "line_height": "Line Height",
   "line_width_is_the_width_of_the_line_in_the_current_environment": "Line width is the width of the line in the current environment. e.g. a full page width in single-column layout or half a page width in a two-column layout.",
   "line_width_is_the_width_of_the_line_in_the_current_environment": "Line width is the width of the line in the current environment. e.g. a full page width in single-column layout or half a page width in a two-column layout.",
   "link": "Link",
   "link": "Link",
@@ -1043,6 +1048,7 @@
   "link_institutional_email_get_started": "Link an institutional email address to your account to get started.",
   "link_institutional_email_get_started": "Link an institutional email address to your account to get started.",
   "link_sharing": "Link sharing",
   "link_sharing": "Link sharing",
   "link_sharing_is_off": "Link sharing is off, only invited users can view this project.",
   "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_sharing_is_on": "Link sharing is on",
   "link_to_github": "Link to your GitHub account",
   "link_to_github": "Link to your GitHub account",
   "link_to_github_description": "You need to authorise __appName__ to access your GitHub account to allow us to sync your projects.",
   "link_to_github_description": "You need to authorise __appName__ to access your GitHub account to allow us to sync your projects.",
@@ -1108,6 +1114,7 @@
   "maintenance": "Maintenance",
   "maintenance": "Maintenance",
   "make_a_copy": "Make a copy",
   "make_a_copy": "Make a copy",
   "make_email_primary_description": "Make this the primary email, used to log in",
   "make_email_primary_description": "Make this the primary email, used to log in",
+  "make_owner": "Make owner",
   "make_primary": "Make Primary",
   "make_primary": "Make Primary",
   "make_private": "Make Private",
   "make_private": "Make Private",
   "manage_beta_program_membership": "Manage Beta Program Membership",
   "manage_beta_program_membership": "Manage Beta Program Membership",
@@ -1495,6 +1502,7 @@
   "react_history_tutorial_title": "History actions have a new home",
   "react_history_tutorial_title": "History actions have a new home",
   "reactivate_subscription": "Reactivate your subscription",
   "reactivate_subscription": "Reactivate your subscription",
   "read_lines_from_path": "Read lines from __path__",
   "read_lines_from_path": "Read lines from __path__",
+  "read_more": "Read more",
   "read_more_about_free_compile_timeouts_servers": "Read more about changes to free compile timeouts and servers",
   "read_more_about_free_compile_timeouts_servers": "Read more about changes to free compile timeouts and servers",
   "read_only": "Read Only",
   "read_only": "Read Only",
   "read_only_token": "Read-Only Token",
   "read_only_token": "Read-Only Token",
@@ -1556,6 +1564,7 @@
   "remind_before_trial_ends": "We’ll remind you before your trial ends",
   "remind_before_trial_ends": "We’ll remind you before your trial ends",
   "remote_service_error": "The remote service produced an error",
   "remote_service_error": "The remote service produced an error",
   "remove": "Remove",
   "remove": "Remove",
+  "remove_access": "Remove access",
   "remove_collaborator": "Remove collaborator",
   "remove_collaborator": "Remove collaborator",
   "remove_from_group": "Remove from group",
   "remove_from_group": "Remove from group",
   "remove_link": "Remove link",
   "remove_link": "Remove link",
@@ -2145,6 +2154,7 @@
   "upgrade_cc_btn": "Upgrade now, pay after 7 days",
   "upgrade_cc_btn": "Upgrade now, pay after 7 days",
   "upgrade_for_12x_more_compile_time": "Upgrade to get 12x more compile time",
   "upgrade_for_12x_more_compile_time": "Upgrade to get 12x more compile time",
   "upgrade_now": "Upgrade Now",
   "upgrade_now": "Upgrade Now",
+  "upgrade_to_add_more_editors": "Upgrade to add more editors to your project",
   "upgrade_to_get_feature": "Upgrade to get __feature__, plus:",
   "upgrade_to_get_feature": "Upgrade to get __feature__, plus:",
   "upgrade_to_track_changes": "Upgrade to track changes",
   "upgrade_to_track_changes": "Upgrade to track changes",
   "upload": "Upload",
   "upload": "Upload",
@@ -2202,6 +2212,7 @@
   "view_pdf": "View PDF",
   "view_pdf": "View PDF",
   "view_source": "View Source",
   "view_source": "View Source",
   "view_your_invoices": "View Your Invoices",
   "view_your_invoices": "View Your Invoices",
+  "viewer": "Viewer",
   "viewing_x": "Viewing <0>__endTime__</0>",
   "viewing_x": "Viewing <0>__endTime__</0>",
   "visual_editor": "Visual Editor",
   "visual_editor": "Visual Editor",
   "visual_editor_is_only_available_for_tex_files": "Visual Editor is only available for TeX files",
   "visual_editor_is_only_available_for_tex_files": "Visual Editor is only available for TeX files",
@@ -2230,6 +2241,7 @@
   "when_you_tick_the_include_caption_box": "When you tick the box “Include caption” the image will be inserted into your document with a placeholder caption. To edit it, you simply select the placeholder text and type to replace it with your own.",
   "when_you_tick_the_include_caption_box": "When you tick the box “Include caption” the image will be inserted into your document with a placeholder caption. To edit it, you simply select the placeholder text and type to replace it with your own.",
   "why_latex": "Why LaTeX?",
   "why_latex": "Why LaTeX?",
   "wide": "Wide",
   "wide": "Wide",
+  "will_lose_edit_access_on_date": "Will lose edit access on __date__",
   "will_need_to_log_out_from_and_in_with": "You will need to <b>log out</b> from your <b>__email1__</b> account and then log in with <b>__email2__</b>.",
   "will_need_to_log_out_from_and_in_with": "You will need to <b>log out</b> from your <b>__email1__</b> account and then log in with <b>__email2__</b>.",
   "with_premium_subscription_you_also_get": "With an Overleaf Premium subscription you also get",
   "with_premium_subscription_you_also_get": "With an Overleaf Premium subscription you also get",
   "word_count": "Word Count",
   "word_count": "Word Count",
@@ -2279,6 +2291,7 @@
   "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are on our <0>__planName__</0> plan as a <1>member</1> of the group subscription <1>__groupName__</1> administered by <1>__adminEmail__</1>",
   "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are on our <0>__planName__</0> plan as a <1>member</1> of the group subscription <1>__groupName__</1> administered by <1>__adminEmail__</1>",
   "you_can_now_enable_sso": "You can now enable SSO on your Group settings page.",
   "you_can_now_enable_sso": "You can now enable SSO on your Group settings page.",
   "you_can_now_log_in_sso": "You can now log in through your institution and if eligible you will receive <0>__appName__ Professional features</0>.",
   "you_can_now_log_in_sso": "You can now log in through your institution and if eligible you will receive <0>__appName__ Professional features</0>.",
+  "you_can_only_add_n_people_to_edit_a_project": "You can only add X people to edit a project with you on your current plan. Upgrade to add more.",
   "you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "You can <0>opt in and out</0> of the program at any time on this page",
   "you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "You can <0>opt in and out</0> of the program at any time on this page",
   "you_can_request_a_maximum_of_limit_fixes_per_day": "You can request a maximum of __limit__ fixes per day. Please try again tomorrow.",
   "you_can_request_a_maximum_of_limit_fixes_per_day": "You can request a maximum of __limit__ fixes per day. Please try again tomorrow.",
   "you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO)</0>.",
   "you_cant_add_or_change_password_due_to_sso": "You can’t add or change your password because your group or organization uses <0>single sign-on (SSO)</0>.",
@@ -2319,6 +2332,7 @@
   "your_password_was_detected": "Your password is on a <0>public list of known compromised passwords</0>. Keep your account safe by changing your password now.",
   "your_password_was_detected": "Your password is on a <0>public list of known compromised passwords</0>. Keep your account safe by changing your password now.",
   "your_plan": "Your plan",
   "your_plan": "Your plan",
   "your_plan_is_changing_at_term_end": "Your plan is changing to <0>__pendingPlanName__</0> at the end of the current billing period.",
   "your_plan_is_changing_at_term_end": "Your plan is changing to <0>__pendingPlanName__</0> at the end of the current billing period.",
+  "your_plan_is_limited_to_n_editors": "Your plan allows [n] collaborators with edit access and unlimited viewers. From [date] any additional editors on this project will be made viewers.",
   "your_project_exceeded_compile_timeout_limit_on_free_plan": "Your project exceeded the compile timeout limit on our free plan.",
   "your_project_exceeded_compile_timeout_limit_on_free_plan": "Your project exceeded the compile timeout limit on our free plan.",
   "your_project_near_compile_timeout_limit": "Your project is near the compile timeout limit for our free plan.",
   "your_project_near_compile_timeout_limit": "Your project is near the compile timeout limit for our free plan.",
   "your_projects": "Your Projects",
   "your_projects": "Your Projects",

+ 54 - 0
services/web/test/frontend/components/shared/select.spec.tsx

@@ -44,9 +44,12 @@ function render(props: RenderProps) {
           itemToSubtitle={props.itemToSubtitle}
           itemToSubtitle={props.itemToSubtitle}
           itemToKey={x => String(x.key)}
           itemToKey={x => String(x.key)}
           onSelectedItemChanged={props.onSelectedItemChanged}
           onSelectedItemChanged={props.onSelectedItemChanged}
+          selected={props.selected}
           disabled={props.disabled}
           disabled={props.disabled}
+          itemToDisabled={props.itemToDisabled}
           optionalLabel={props.optionalLabel}
           optionalLabel={props.optionalLabel}
           loading={props.loading}
           loading={props.loading}
+          selectedIcon={props.selectedIcon}
         />
         />
         <button type="submit">submit</button>
         <button type="submit">submit</button>
       </form>
       </form>
@@ -275,4 +278,55 @@ describe('<Select />', function () {
       cy.findByText('Demo item 2').should('not.exist')
       cy.findByText('Demo item 2').should('not.exist')
     })
     })
   })
   })
+
+  describe('selectedIcon', function () {
+    it('renders a selected icon if the prop is set', function () {
+      render({
+        defaultText: 'Choose an item',
+        selectedIcon: true,
+      })
+      cy.findByText('Choose an item').click()
+      cy.findByText('Demo item 1').click()
+      cy.findByText('Demo item 1').click()
+
+      cy.get('.fa-check').should('exist')
+    })
+    it('renders no selected icon if the prop is not set', function () {
+      render({
+        defaultText: 'Choose an item',
+        selectedIcon: false,
+      })
+      cy.findByText('Choose an item').click()
+      cy.findByText('Demo item 1').click()
+      cy.findByText('Demo item 1').click()
+
+      cy.get('.fa-check').should('not.exist')
+    })
+  })
+
+  describe('itemToDisabled', function () {
+    it('prevents selecting a disabled item', function () {
+      render({
+        defaultText: 'Choose an item',
+        itemToDisabled: x => x?.key === 2,
+      })
+      cy.findByText('Choose an item').click()
+      cy.findByText('Demo item 2').click()
+      // still showing other list items
+      cy.findByText('Demo item 3').should('exist')
+      cy.findByText('Demo item 1').click()
+      // clicking an enabled item dismisses the list
+      cy.findByText('Demo item 3').should('not.exist')
+    })
+  })
+
+  describe('selected', function () {
+    it('shows the item provided in the selected prop', function () {
+      render({
+        defaultText: 'Choose an item',
+        selected: testData[1],
+      })
+      cy.findByText('Demo item 2').should('exist')
+    })
+  })
 })
 })

+ 1 - 0
services/web/test/unit/src/Project/ProjectControllerTests.js

@@ -164,6 +164,7 @@ describe('ProjectController', function () {
     this.SplitTestHandler = {
     this.SplitTestHandler = {
       promises: {
       promises: {
         getAssignment: sinon.stub().resolves({ variant: 'default' }),
         getAssignment: sinon.stub().resolves({ variant: 'default' }),
+        getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
       },
       },
       getAssignment: sinon.stub().yields(null, { variant: 'default' }),
       getAssignment: sinon.stub().yields(null, { variant: 'default' }),
     }
     }

+ 1 - 1
services/web/types/project.ts

@@ -1,7 +1,7 @@
 import { MongoUser } from './user'
 import { MongoUser } from './user'
 import { Folder } from './folder'
 import { Folder } from './folder'
 
 
-type ProjectMember = {
+export type ProjectMember = {
   _id: string
   _id: string
   type: 'user'
   type: 'user'
   privileges: 'readOnly' | 'readAndWrite'
   privileges: 'readOnly' | 'readAndWrite'