diff --git a/services/web/.storybook/preview.css b/services/web/.storybook/preview.css
index 9a39b51ac5..0fb8660505 100644
--- a/services/web/.storybook/preview.css
+++ b/services/web/.storybook/preview.css
@@ -1,3 +1,7 @@
+.sb-show-main.modal-open {
+ overflow-y: auto !important;
+}
+
.sb-show-main .modal-backdrop {
display: none;
}
diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js
index 03d8c1c8cf..38c15aafaf 100644
--- a/services/web/app/src/Features/Project/ProjectController.js
+++ b/services/web/app/src/Features/Project/ProjectController.js
@@ -744,6 +744,9 @@ const ProjectController = {
)
const wantsOldFileTreeUI =
req.query && req.query.new_file_tree_ui === 'false'
+ const wantsNewShareModalUI =
+ req.query && req.query.new_share_modal_ui === 'true'
+
AuthorizationManager.getPrivilegeLevelForProject(
userId,
projectId,
@@ -857,7 +860,8 @@ const ProjectController = {
showNewLogsUI: userShouldSeeNewLogsUI && !wantsOldLogsUI,
showNewNavigationUI:
req.query && req.query.new_navigation_ui === 'true',
- showReactFileTree: !wantsOldFileTreeUI
+ showReactFileTree: !wantsOldFileTreeUI,
+ showReactShareModal: wantsNewShareModalUI
})
timer.done()
}
diff --git a/services/web/app/views/project/editor/header.pug b/services/web/app/views/project/editor/header.pug
index 8c0a8f8523..47d3c2bf03 100644
--- a/services/web/app/views/project/editor/header.pug
+++ b/services/web/app/views/project/editor/header.pug
@@ -119,11 +119,19 @@ header.toolbar.toolbar-header.toolbar-with-labels(
a.btn.btn-full-height(
href
ng-click="openShareProjectModal(permissions.admin);"
- ng-controller="ShareController"
+ ng-controller=(showReactShareModal ? 'ReactShareProjectModalController': 'ShareController')
)
i.fa.fa-fw.fa-group
p.toolbar-label #{translate("share")}
+ if showReactShareModal
+ share-project-modal(
+ handle-hide="handleHide"
+ show="show"
+ is-admin="isAdmin"
+ project="clonedProject"
+ update-project="updateProject"
+ )
!= moduleIncludes('publish:button', locals)
if !isRestrictedTokenMember
diff --git a/services/web/app/views/project/editor/share.pug b/services/web/app/views/project/editor/share.pug
index 77777e93a7..02d1df3308 100644
--- a/services/web/app/views/project/editor/share.pug
+++ b/services/web/app/views/project/editor/share.pug
@@ -142,6 +142,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
.small #{translate("share_with_your_collabs")}
.form-group
tags-input(
+ class="tags-input"
template="shareTagTemplate"
placeholder=settings.customisation.shareProjectPlaceholder || 'joe@example.com, sue@example.com, …'
ng-model="inputs.contacts"
diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 8767999001..efb799db81 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -1,4 +1,6 @@
{
+ "anyone_with_link_can_edit": "",
+ "anyone_with_link_can_view": "",
"ask_proj_owner_to_upgrade_for_longer_compiles": "",
"auto_compile": "",
"autocompile_disabled": "",
@@ -7,9 +9,19 @@
"autocomplete_references": "",
"back_to_your_projects": "",
"blocked_filename": "",
+ "can_edit": "",
"cancel": "",
+ "cannot_invite_non_user": "",
+ "cannot_invite_self": "",
+ "cannot_verify_user_not_robot": "",
+ "change_or_cancel-cancel": "",
+ "change_or_cancel-change": "",
+ "change_or_cancel-or": "",
+ "change_owner": "",
+ "change_project_owner": "",
"chat": "",
"clear_cached_files": "",
+ "close": "",
"clsi_maintenance": "",
"clsi_unavailable": "",
"collabs_per_proj": "",
@@ -21,8 +33,8 @@
"compile_mode": "",
"compile_terminated_by_user": "",
"compiling": "",
- "connected_users": "",
"conflicting_paths_found": "",
+ "connected_users": "",
"copy": "",
"copy_project": "",
"copying": "",
@@ -57,8 +69,13 @@
"history": "",
"hotkeys": "",
"ignore_validation_errors": "",
+ "invalid_email": "",
"invalid_file_name": "",
+ "invite_not_accepted": "",
"learn_how_to_make_documents_compile_quickly": "",
+ "learn_more_about_link_sharing": "",
+ "link_sharing_is_off": "",
+ "link_sharing_is_on": "",
"linked_file": "",
"loading": "",
"log_entry_description": "",
@@ -66,6 +83,7 @@
"logs_pane_beta_message": "",
"logs_pane_beta_message_popup": "",
"main_file_not_found": "",
+ "make_private": "",
"math_display": "",
"math_inline": "",
"menu": "",
@@ -77,6 +95,7 @@
"n_warnings_plural": "",
"navigate_log_source": "",
"navigation": "",
+ "need_to_upgrade_for_more_collabs": "",
"new_file": "",
"new_folder": "",
"new_name": "",
@@ -87,6 +106,7 @@
"on": "",
"other_logs_and_files": "",
"other_output_files": "",
+ "owner": "",
"pdf_compile_in_progress_error": "",
"pdf_compile_rate_limit_hit": "",
"pdf_compile_try_again": "",
@@ -97,19 +117,28 @@
"plus_upgraded_accounts_receive": "",
"proj_timed_out_reason": "",
"project_flagged_too_many_compiles": "",
+ "project_ownership_transfer_confirmation_1": "",
+ "project_ownership_transfer_confirmation_2": "",
"project_too_large": "",
"project_too_large_please_reduce": "",
"raw_logs": "",
"raw_logs_description": "",
+ "read_only": "",
"recompile": "",
"recompile_from_scratch": "",
"refresh": "",
+ "refresh_page_after_starting_free_trial": "",
+ "remove_collaborator": "",
"rename": "",
+ "resend": "",
"review": "",
+ "revoke_invite": "",
"run_syntax_check_now": "",
"send_first_message": "",
"server_error": "",
"share": "",
+ "share_project": "",
+ "share_with_your_collabs": "",
"show_outline": "",
"something_went_wrong_rendering_pdf": "",
"somthing_went_wrong_compiling": "",
@@ -121,11 +150,18 @@
"sync_to_dropbox": "",
"sync_to_github": "",
"terminated": "",
+ "this_project_is_public": "",
+ "this_project_is_public_read_only": "",
"timedout": "",
+ "to_add_more_collaborators": "",
+ "to_change_access_permissions": "",
"toggle_compile_options_menu": "",
"toggle_output_files_list": "",
+ "too_many_requests": "",
"too_recently_compiled": "",
"total_words": "",
+ "turn_off_link_sharing": "",
+ "turn_on_link_sharing": "",
"unlimited_projects": "",
"upgrade_for_longer_compiles": "",
"upload": "",
diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js
new file mode 100644
index 0000000000..21977a5a80
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.js
@@ -0,0 +1,90 @@
+import React, { useState } from 'react'
+import { Trans } from 'react-i18next'
+import { Button } from 'react-bootstrap'
+import Icon from '../../../shared/components/icon'
+import { startFreeTrial, upgradePlan } from '../../../main/account-upgrade'
+import { useShareProjectContext } from './share-project-modal'
+
+export default function AddCollaboratorsUpgrade() {
+ const { eventTracking } = useShareProjectContext()
+
+ const [startedFreeTrial, setStartedFreeTrial] = useState(false)
+
+ return (
+
+
+ . Also:
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
+
+
+ {window.user.allowedFreeTrial ? (
+
+ ) : (
+
+ )}
+
+ {startedFreeTrial && (
+
+
+
+ )}
+
+ )
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators.js b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.js
new file mode 100644
index 0000000000..66d062ea94
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.js
@@ -0,0 +1,161 @@
+import React, { useEffect, useState, useMemo, useRef } from 'react'
+import { useTranslation, Trans } from 'react-i18next'
+import { Form, FormGroup, FormControl, Button } from 'react-bootstrap'
+import { useMultipleSelection } from 'downshift'
+import {
+ useProjectContext,
+ useShareProjectContext
+} from './share-project-modal'
+import SelectCollaborators from './select-collaborators'
+import { resendInvite, sendInvite } from '../utils/api'
+import { useUserContacts } from '../hooks/use-user-contacts'
+
+export default function AddCollaborators() {
+ const [privileges, setPrivileges] = useState('readAndWrite')
+
+ const isMounted = useRef(true)
+
+ // the component will be unmounted if the project can't have any more collaborators
+ useEffect(() => {
+ isMounted.current = true
+
+ return () => {
+ isMounted.current = false
+ }
+ }, [isMounted])
+
+ const { data: contacts } = useUserContacts()
+
+ const { t } = useTranslation()
+
+ const { updateProject, setInFlight, setError } = useShareProjectContext()
+
+ const project = useProjectContext()
+
+ const currentMemberEmails = useMemo(
+ () => (project.members || []).map(member => member.email).sort(),
+ [project.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
+
+ async function handleSubmit(event) {
+ event.preventDefault()
+
+ 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 = (project.invites || []).find(
+ invite => invite.email === normalisedEmail
+ )
+
+ if (invite) {
+ data = await resendInvite(project, invite)
+ } else {
+ data = await sendInvite(project, email, privileges)
+ }
+ } 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: project.invites.concat(data.invite)
+ })
+ } else if (data.users) {
+ updateProject({
+ members: project.members.concat(data.users)
+ })
+ } else if (data.user) {
+ updateProject({
+ members: project.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)
+ }
+
+ return (
+
+ )
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/edit-member.js b/services/web/frontend/js/features/share-project-modal/components/edit-member.js
new file mode 100644
index 0000000000..4d084c36a4
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/edit-member.js
@@ -0,0 +1,180 @@
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import { Trans, useTranslation } from 'react-i18next'
+import {
+ useProjectContext,
+ useShareProjectContext
+} from './share-project-modal'
+import Icon from '../../../shared/components/icon'
+import TransferOwnershipModal from './transfer-ownership-modal'
+import {
+ Button,
+ Col,
+ Form,
+ FormControl,
+ FormGroup,
+ OverlayTrigger,
+ Tooltip
+} from 'react-bootstrap'
+import { removeMemberFromProject, updateMember } from '../utils/api'
+
+export default function EditMember({ member }) {
+ const [privileges, setPrivileges] = useState(member.privileges)
+ const [
+ confirmingOwnershipTransfer,
+ setConfirmingOwnershipTransfer
+ ] = useState(false)
+
+ const { updateProject, monitorRequest } = useShareProjectContext()
+ const project = useProjectContext()
+
+ function handleSubmit(event) {
+ event.preventDefault()
+
+ if (privileges === 'owner') {
+ setConfirmingOwnershipTransfer(true)
+ } else {
+ monitorRequest(() =>
+ updateMember(project, member, {
+ privilegeLevel: privileges
+ })
+ ).then(() => {
+ updateProject({
+ members: project.members.map(item =>
+ item._id === member._id ? { ...item, privileges } : item
+ )
+ })
+ })
+ }
+ }
+
+ if (confirmingOwnershipTransfer) {
+ return (
+ setConfirmingOwnershipTransfer(false)}
+ />
+ )
+ }
+
+ return (
+
+ )
+}
+EditMember.propTypes = {
+ member: PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ email: PropTypes.string.isRequired,
+ privileges: PropTypes.string.isRequired
+ })
+}
+
+function SelectPrivilege({ value, handleChange }) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+
+ )
+}
+SelectPrivilege.propTypes = {
+ value: PropTypes.string.isRequired,
+ handleChange: PropTypes.func.isRequired
+}
+
+function RemoveMemberAction({ member }) {
+ const { updateProject, monitorRequest } = useShareProjectContext()
+ const project = useProjectContext()
+
+ function handleClick(event) {
+ event.preventDefault()
+
+ monitorRequest(() => removeMemberFromProject(project, member)).then(() => {
+ updateProject({
+ members: project.members.filter(existing => existing !== member)
+ })
+ })
+ }
+
+ return (
+
+
+
+
+ }
+ >
+
+
+
+ )
+}
+RemoveMemberAction.propTypes = {
+ member: PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ email: PropTypes.string.isRequired,
+ privileges: PropTypes.string.isRequired
+ })
+}
+
+function ChangePrivilegesActions({ handleReset }) {
+ return (
+
+ )
+}
+ChangePrivilegesActions.propTypes = {
+ handleReset: PropTypes.func.isRequired
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/invite.js b/services/web/frontend/js/features/share-project-modal/components/invite.js
new file mode 100644
index 0000000000..d6cde0cf4c
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/invite.js
@@ -0,0 +1,114 @@
+import React, { useCallback } from 'react'
+import PropTypes from 'prop-types'
+import {
+ useProjectContext,
+ useShareProjectContext
+} from './share-project-modal'
+import Icon from '../../../shared/components/icon'
+import { Button, Col, Row, OverlayTrigger, Tooltip } from 'react-bootstrap'
+import { Trans } from 'react-i18next'
+import MemberPrivileges from './member-privileges'
+import { resendInvite, revokeInvite } from '../utils/api'
+
+export default function Invite({ invite, isAdmin }) {
+ return (
+
+
+ {invite.email}
+
+
+
+ .
+ {isAdmin && }
+
+
+
+
+
+
+
+ {isAdmin && (
+
+
+
+ )}
+
+ )
+}
+
+Invite.propTypes = {
+ invite: PropTypes.object.isRequired,
+ isAdmin: PropTypes.bool.isRequired
+}
+
+function ResendInvite({ invite }) {
+ const { monitorRequest } = useShareProjectContext()
+ const project = useProjectContext()
+
+ // const buttonRef = useRef(null)
+ //
+ const handleClick = useCallback(
+ () =>
+ monitorRequest(() => resendInvite(project, 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, project]
+ )
+
+ return (
+
+ )
+}
+ResendInvite.propTypes = {
+ invite: PropTypes.object.isRequired
+}
+
+function RevokeInvite({ invite }) {
+ const { updateProject, monitorRequest } = useShareProjectContext()
+ const project = useProjectContext()
+
+ function handleClick(event) {
+ event.preventDefault()
+
+ monitorRequest(() => revokeInvite(project, invite)).then(() => {
+ updateProject({
+ invites: project.invites.filter(existing => existing !== invite)
+ })
+ })
+ }
+
+ return (
+
+
+
+ }
+ >
+
+
+ )
+}
+RevokeInvite.propTypes = {
+ invite: PropTypes.object.isRequired
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/link-sharing.js b/services/web/frontend/js/features/share-project-modal/components/link-sharing.js
new file mode 100644
index 0000000000..e18c112bc7
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/link-sharing.js
@@ -0,0 +1,246 @@
+import React, { useCallback, useState } from 'react'
+import PropTypes from 'prop-types'
+import { Button, Col, OverlayTrigger, Row, Tooltip } from 'react-bootstrap'
+import { Trans } from 'react-i18next'
+import Icon from '../../../shared/components/icon'
+import {
+ useProjectContext,
+ useShareProjectContext
+} from './share-project-modal'
+import { setProjectAccessLevel } from '../utils/api'
+import CopyLink from '../../../shared/components/copy-link'
+
+export default function LinkSharing() {
+ const [inflight, setInflight] = useState(false)
+
+ const { monitorRequest, updateProject } = useShareProjectContext()
+
+ const project = useProjectContext()
+
+ // set the access level of a project
+ const setAccessLevel = useCallback(
+ publicAccesLevel => {
+ setInflight(true)
+ monitorRequest(() => setProjectAccessLevel(project, publicAccesLevel))
+ .then(() => {
+ // TODO: ideally this would use the response from the server
+ updateProject({ publicAccesLevel })
+ // TODO: eventTracking.sendMB('project-make-token-based') when publicAccesLevel is 'tokenBased'
+ })
+ .finally(() => {
+ setInflight(false)
+ })
+ },
+ [monitorRequest, project, updateProject]
+ )
+
+ switch (project.publicAccesLevel) {
+ // Private (with token-access available)
+ case 'private':
+ return (
+
+ )
+
+ // Token-based access
+ case 'tokenBased':
+ return (
+
+ )
+
+ // Legacy public-access
+ case 'readAndWrite':
+ case 'readOnly':
+ return (
+
+ )
+
+ default:
+ return null
+ }
+}
+
+function PrivateSharing({ setAccessLevel, inflight }) {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
+PrivateSharing.propTypes = {
+ setAccessLevel: PropTypes.func.isRequired,
+ inflight: PropTypes.bool
+}
+
+function TokenBasedSharing({ setAccessLevel, inflight }) {
+ const project = useProjectContext()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+TokenBasedSharing.propTypes = {
+ setAccessLevel: PropTypes.func.isRequired,
+ inflight: PropTypes.bool
+}
+
+function LegacySharing({ accessLevel, setAccessLevel, inflight }) {
+ return (
+
+
+
+ {accessLevel === 'readAndWrite' && (
+
+ )}
+ {accessLevel === 'readOnly' && (
+
+ )}
+
+
+
+
+
+
+
+ )
+}
+LegacySharing.propTypes = {
+ accessLevel: PropTypes.string.isRequired,
+ setAccessLevel: PropTypes.func.isRequired,
+ inflight: PropTypes.bool
+}
+
+export function ReadOnlyTokenLink() {
+ const project = useProjectContext()
+
+ return (
+
+
+
+
+
+ )
+}
+
+function AccessToken({ token, path, tooltipId }) {
+ if (!token) {
+ return (
+
+
+ …
+
+
+ )
+ }
+
+ const link = `${window.location.origin}${path}${token}`
+
+ return (
+
+ {link}
+
+
+ )
+}
+AccessToken.propTypes = {
+ token: PropTypes.string,
+ tooltipId: PropTypes.string.isRequired,
+ path: PropTypes.string.isRequired
+}
+
+function LinkSharingInfo() {
+ return (
+
+
+
+ }
+ >
+
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/member-privileges.js b/services/web/frontend/js/features/share-project-modal/components/member-privileges.js
new file mode 100644
index 0000000000..a82d37ea2b
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/member-privileges.js
@@ -0,0 +1,19 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Trans } from 'react-i18next'
+
+export default function MemberPrivileges({ privileges }) {
+ switch (privileges) {
+ case 'readAndWrite':
+ return
+
+ case 'readOnly':
+ return
+
+ default:
+ return null
+ }
+}
+MemberPrivileges.propTypes = {
+ privileges: PropTypes.string.isRequired
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/owner-info.js b/services/web/frontend/js/features/share-project-modal/components/owner-info.js
new file mode 100644
index 0000000000..affb80e072
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/owner-info.js
@@ -0,0 +1,17 @@
+import React from 'react'
+import { useProjectContext } from './share-project-modal'
+import { Col, Row } from 'react-bootstrap'
+import { Trans } from 'react-i18next'
+
+export default function OwnerInfo() {
+ const project = useProjectContext()
+
+ return (
+
+ {project.owner?.email}
+
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/select-collaborators.js b/services/web/frontend/js/features/share-project-modal/components/select-collaborators.js
new file mode 100644
index 0000000000..3bee5d7136
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/select-collaborators.js
@@ -0,0 +1,312 @@
+import React, { useMemo, useState, useRef, useCallback } from 'react'
+import PropTypes from 'prop-types'
+import { Trans } from 'react-i18next'
+import { matchSorter } from 'match-sorter'
+import { useCombobox } from 'downshift'
+import classnames from 'classnames'
+
+import Icon from '../../../shared/components/icon'
+
+export default function SelectCollaborators({
+ loading,
+ options,
+ placeholder,
+ multipleSelectionProps
+}) {
+ 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(
+ () =>
+ matchSorter(unselectedOptions, inputValue, {
+ keys: ['name', 'email'],
+ threshold: matchSorter.rankings.CONTAINS
+ }),
+ [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 addNewItem = useCallback(
+ (email, focus = true) => {
+ if (
+ isValidInput &&
+ email.includes('@') &&
+ !selectedEmails.includes(email)
+ ) {
+ addSelectedItem({
+ email,
+ display: email,
+ type: 'user'
+ })
+ setInputValue('')
+ if (focus) {
+ focusInput()
+ }
+ return true
+ }
+ },
+ [addSelectedItem, selectedEmails, isValidInput, focusInput]
+ )
+
+ const {
+ isOpen,
+ getLabelProps,
+ getMenuProps,
+ getInputProps,
+ getComboboxProps,
+ highlightedIndex,
+ getItemProps
+ } = useCombobox({
+ inputValue,
+ defaultHighlightedIndex: 0,
+ items: filteredOptions,
+ itemToString: item => item && item.name,
+ onStateChange: ({ inputValue, type, selectedItem }) => {
+ switch (type) {
+ // set inputValue when the input changes
+ case useCombobox.stateChangeTypes.InputChange:
+ setInputValue(inputValue)
+ break
+
+ // 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 showDropdownItems =
+ isOpen && inputValue.length > 0 && filteredOptions.length > 0
+
+ return (
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-for */}
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
+
+ {selectedItems.map((selectedItem, index) => (
+
+ ))}
+
+ {
+ // blur: if the dropdown isn't visible, try to create a new item using inputValue
+ if (!showDropdownItems) {
+ addNewItem(inputValue, false)
+ }
+ },
+ 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 visible, try to create a new item using inputValue and prevent blur if successful
+ if (!showDropdownItems && 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(/\s*,\s*/)
+ .filter(item => item.includes('@'))
+
+ if (emails.length) {
+ // pasted comma-separated email addresses
+ event.preventDefault()
+
+ for (const email of emails) {
+ addNewItem(email)
+ }
+ }
+ }
+ }
+ })
+ )}
+ />
+
+
+
+
+ {showDropdownItems &&
+ filteredOptions.map((item, index) => (
+
+ ))}
+
+
+
+
+ )
+}
+SelectCollaborators.propTypes = {
+ loading: PropTypes.bool.isRequired,
+ options: PropTypes.array.isRequired,
+ placeholder: PropTypes.string,
+ multipleSelectionProps: PropTypes.shape({
+ getSelectedItemProps: PropTypes.func.isRequired,
+ getDropdownProps: PropTypes.func.isRequired,
+ addSelectedItem: PropTypes.func.isRequired,
+ removeSelectedItem: PropTypes.func.isRequired,
+ selectedItems: PropTypes.array.isRequired
+ }).isRequired
+}
+
+function Option({ selected, item, getItemProps, index }) {
+ return (
+
+
+
+ {item.display}
+
+ )
+}
+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 handleClick = useCallback(
+ event => {
+ event.preventDefault()
+ event.stopPropagation()
+ removeSelectedItem(selectedItem)
+ focusInput()
+ },
+ [focusInput, removeSelectedItem, selectedItem]
+ )
+
+ return (
+
+
+ {selectedItem.display}
+
+
+ )
+}
+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
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/send-invites-notice.js b/services/web/frontend/js/features/share-project-modal/components/send-invites-notice.js
new file mode 100644
index 0000000000..8a3ddcd460
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/send-invites-notice.js
@@ -0,0 +1,33 @@
+import React from 'react'
+import { Col, Row } from 'react-bootstrap'
+import PropTypes from 'prop-types'
+import { Trans } from 'react-i18next'
+import { useProjectContext } from './share-project-modal'
+
+export default function SendInvitesNotice() {
+ const project = useProjectContext()
+
+ return (
+
+
+
+
+
+ )
+}
+
+function AccessLevel({ level }) {
+ switch (level) {
+ case 'private':
+ return
+
+ case 'tokenBased':
+ return
+
+ default:
+ return null
+ }
+}
+AccessLevel.propTypes = {
+ level: PropTypes.string
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/send-invites.js b/services/web/frontend/js/features/share-project-modal/components/send-invites.js
new file mode 100644
index 0000000000..c44790ac60
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/send-invites.js
@@ -0,0 +1,32 @@
+import React, { useMemo } from 'react'
+import { Row } from 'react-bootstrap'
+import { useProjectContext } from './share-project-modal'
+import AddCollaborators from './add-collaborators'
+import AddCollaboratorsUpgrade from './add-collaborators-upgrade'
+
+export default function SendInvites() {
+ const project = useProjectContext()
+
+ // whether the project has not reached the collaborator limit
+ const canAddCollaborators = useMemo(() => {
+ if (!project) {
+ return false
+ }
+
+ if (project.features.collaborators === -1) {
+ // infinite collaborators
+ return true
+ }
+
+ return (
+ project.members.length + project.invites.length <
+ project.features.collaborators
+ )
+ }, [project])
+
+ return (
+
+ {canAddCollaborators ? : }
+
+ )
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/share-modal-body.js b/services/web/frontend/js/features/share-project-modal/components/share-modal-body.js
new file mode 100644
index 0000000000..2c0c6ca824
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/share-modal-body.js
@@ -0,0 +1,40 @@
+import React from 'react'
+import {
+ useProjectContext,
+ useShareProjectContext
+} from './share-project-modal'
+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'
+
+export default function ShareModalBody() {
+ const { isAdmin } = useShareProjectContext()
+
+ const project = useProjectContext()
+
+ return (
+ <>
+ {isAdmin && }
+
+
+
+ {project.members.map(member =>
+ isAdmin ? (
+
+ ) : (
+
+ )
+ )}
+
+ {project.invites.map(invite => (
+
+ ))}
+
+ {isAdmin ? : }
+ >
+ )
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.js b/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.js
new file mode 100644
index 0000000000..3acc2c5797
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.js
@@ -0,0 +1,95 @@
+import React from 'react'
+import { Button, Modal, Grid } from 'react-bootstrap'
+import { Trans } from 'react-i18next'
+import ShareModalBody from './share-modal-body'
+import Icon from '../../../shared/components/icon'
+import AccessibleModal from '../../../shared/components/accessible-modal'
+import PropTypes from 'prop-types'
+import { ReadOnlyTokenLink } from './link-sharing'
+
+export default function ShareProjectModalContent({
+ show,
+ cancel,
+ animation,
+ inFlight,
+ error
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ {window.isRestrictedTokenMember ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {inFlight && }
+ {error && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ )
+}
+ShareProjectModalContent.propTypes = {
+ cancel: PropTypes.func.isRequired,
+ show: PropTypes.bool,
+ animation: PropTypes.bool,
+ inFlight: PropTypes.bool,
+ error: PropTypes.string
+}
+
+function ErrorMessage({ error }) {
+ switch (error) {
+ case 'cannot_invite_non_user':
+ return (
+
+ )
+
+ case 'cannot_verify_user_not_robot':
+ return
+
+ case 'cannot_invite_self':
+ return
+
+ case 'invalid_email':
+ return
+
+ case 'too_many_requests':
+ return
+
+ default:
+ return
+ }
+}
+ErrorMessage.propTypes = {
+ error: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]).isRequired
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/share-project-modal.js b/services/web/frontend/js/features/share-project-modal/components/share-project-modal.js
new file mode 100644
index 0000000000..16e5c18059
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/share-project-modal.js
@@ -0,0 +1,171 @@
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useState
+} from 'react'
+import PropTypes from 'prop-types'
+import ShareProjectModalContent from './share-project-modal-content'
+
+const ShareProjectContext = createContext()
+
+ShareProjectContext.Provider.propTypes = {
+ value: PropTypes.shape({
+ isAdmin: PropTypes.bool.isRequired,
+ updateProject: PropTypes.func.isRequired,
+ monitorRequest: PropTypes.func.isRequired,
+ eventTracking: PropTypes.shape({
+ sendMB: PropTypes.func.isRequired
+ }),
+ inFlight: PropTypes.bool,
+ setInFlight: PropTypes.func,
+ error: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]),
+ setError: PropTypes.func
+ })
+}
+
+export function useShareProjectContext() {
+ const context = useContext(ShareProjectContext)
+
+ if (!context) {
+ throw new Error(
+ 'useShareProjectContext is only available inside ShareProjectProvider'
+ )
+ }
+
+ return context
+}
+
+const projectShape = PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ members: PropTypes.arrayOf(
+ PropTypes.shape({
+ _id: PropTypes.string.isRequired
+ })
+ ),
+ invites: PropTypes.arrayOf(
+ PropTypes.shape({
+ _id: PropTypes.string.isRequired
+ })
+ ),
+ name: PropTypes.string,
+ features: PropTypes.shape({
+ collaborators: PropTypes.number
+ }),
+ publicAccesLevel: PropTypes.string,
+ tokens: PropTypes.shape({
+ readOnly: PropTypes.string,
+ readAndWrite: PropTypes.string
+ }),
+ owner: PropTypes.shape({
+ email: PropTypes.string
+ })
+})
+
+const ProjectContext = createContext()
+
+ProjectContext.Provider.propTypes = {
+ value: projectShape
+}
+
+export function useProjectContext() {
+ const context = useContext(ProjectContext)
+
+ if (!context) {
+ throw new Error(
+ 'useProjectContext is only available inside ShareProjectProvider'
+ )
+ }
+
+ return context
+}
+
+export default function ShareProjectModal({
+ handleHide,
+ show,
+ animation = true,
+ isAdmin,
+ eventTracking,
+ project,
+ updateProject
+}) {
+ const [inFlight, setInFlight] = useState(false)
+ const [error, setError] = useState()
+
+ // 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 => {
+ setError(
+ error.data?.errorReason ||
+ error.data?.error ||
+ 'generic_something_went_wrong'
+ )
+ })
+
+ promise.finally(() => {
+ setInFlight(false)
+ })
+
+ return promise
+ }, [])
+
+ if (!project) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+ShareProjectModal.propTypes = {
+ animation: PropTypes.bool,
+ handleHide: PropTypes.func.isRequired,
+ isAdmin: PropTypes.bool.isRequired,
+ project: projectShape,
+ show: PropTypes.bool.isRequired,
+ eventTracking: PropTypes.shape({
+ sendMB: PropTypes.func.isRequired
+ }),
+ updateProject: PropTypes.func.isRequired
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.js b/services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.js
new file mode 100644
index 0000000000..81d209d287
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/transfer-ownership-modal.js
@@ -0,0 +1,84 @@
+import React, { useState } from 'react'
+import { Modal, Button } from 'react-bootstrap'
+import { Trans } from 'react-i18next'
+import PropTypes from 'prop-types'
+import { useProjectContext } from './share-project-modal'
+import Icon from '../../../shared/components/icon'
+import { transferProjectOwnership } from '../utils/api'
+import AccessibleModal from '../../../shared/components/accessible-modal'
+import { reload } from '../utils/location'
+
+export default function TransferOwnershipModal({ member, cancel }) {
+ const [inflight, setInflight] = useState(false)
+ const [error, setError] = useState(false)
+
+ const project = useProjectContext()
+
+ function confirm() {
+ setError(false)
+ setInflight(true)
+
+ transferProjectOwnership(project, member)
+ .then(() => {
+ reload()
+ })
+ .catch(() => {
+ setError(true)
+ setInflight(false)
+ })
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ , ]}
+ />
+
+
+
+
+
+
+
+ {inflight && }
+ {error && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ )
+}
+TransferOwnershipModal.propTypes = {
+ member: PropTypes.object.isRequired,
+ cancel: PropTypes.func.isRequired
+}
diff --git a/services/web/frontend/js/features/share-project-modal/components/view-member.js b/services/web/frontend/js/features/share-project-modal/components/view-member.js
new file mode 100644
index 0000000000..423a1a6f1c
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/components/view-member.js
@@ -0,0 +1,23 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import { Col, Row } from 'react-bootstrap'
+import MemberPrivileges from './member-privileges'
+
+export default function ViewMember({ member }) {
+ return (
+
+ {member.email}
+
+
+
+
+ )
+}
+
+ViewMember.propTypes = {
+ member: PropTypes.shape({
+ _id: PropTypes.string.isRequired,
+ email: PropTypes.string.isRequired,
+ privileges: PropTypes.string.isRequired
+ }).isRequired
+}
diff --git a/services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js b/services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js
new file mode 100644
index 0000000000..4f193269ec
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/controllers/react-share-project-modal-controller.js
@@ -0,0 +1,99 @@
+import App from '../../../base'
+import { react2angular } from 'react2angular'
+import cloneDeep from 'lodash/cloneDeep'
+
+import ShareProjectModal from '../components/share-project-modal'
+import { listProjectInvites, listProjectMembers } from '../utils/api'
+
+App.component('shareProjectModal', react2angular(ShareProjectModal))
+
+export default App.controller('ReactShareProjectModalController', function(
+ $scope,
+ eventTracking,
+ ide
+) {
+ $scope.isAdmin = false
+ $scope.show = false
+
+ let deregisterProjectWatch
+
+ // deep watch $scope.project for changes
+ function registerProjectWatch() {
+ deregisterProjectWatch = $scope.$watch(
+ 'project',
+ project => {
+ $scope.clonedProject = cloneDeep(project)
+ },
+ true
+ )
+ }
+
+ $scope.handleHide = () => {
+ $scope.$applyAsync(() => {
+ $scope.show = false
+ if (deregisterProjectWatch) {
+ deregisterProjectWatch()
+ }
+ })
+ }
+
+ $scope.openShareProjectModal = isAdmin => {
+ eventTracking.sendMBOnce('ide-open-share-modal-once')
+ $scope.$applyAsync(() => {
+ registerProjectWatch()
+
+ $scope.isAdmin = isAdmin
+ $scope.show = true
+ })
+ }
+
+ // update $scope.project with new data
+ $scope.updateProject = data => {
+ if (!$scope.project) {
+ return
+ }
+
+ $scope.$applyAsync(() => {
+ Object.assign($scope.project, data)
+ })
+ }
+
+ /* tokens */
+
+ ide.socket.on('project:tokens:changed', data => {
+ if (data.tokens != null) {
+ ide.$scope.project.tokens = data.tokens
+ $scope.$digest()
+ }
+ })
+
+ ide.socket.on('project:membership:changed', data => {
+ if (data.members) {
+ listProjectMembers($scope.project)
+ .then(({ members }) => {
+ if (members) {
+ $scope.$applyAsync(() => {
+ $scope.project.members = members
+ })
+ }
+ })
+ .catch(() => {
+ console.error('Error fetching members for project')
+ })
+ }
+
+ if (data.invites) {
+ listProjectInvites($scope.project)
+ .then(({ invites }) => {
+ if (invites) {
+ $scope.$applyAsync(() => {
+ $scope.project.invites = invites
+ })
+ }
+ })
+ .catch(() => {
+ console.error('Error fetching invites for project')
+ })
+ }
+ })
+})
diff --git a/services/web/frontend/js/features/share-project-modal/hooks/use-user-contacts.js b/services/web/frontend/js/features/share-project-modal/hooks/use-user-contacts.js
new file mode 100644
index 0000000000..5dd5d08823
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/hooks/use-user-contacts.js
@@ -0,0 +1,42 @@
+import { useEffect, useState } from 'react'
+import { getJSON } from '../../../infrastructure/fetch-json'
+
+const contactCollator = new Intl.Collator('en')
+
+const alphabetical = (a, b) =>
+ contactCollator.compare(a.name, b.name) ||
+ contactCollator.compare(a.email, b.email)
+
+export function useUserContacts() {
+ const [loading, setLoading] = useState(true)
+ const [data, setData] = useState(null)
+ const [error, setError] = useState(false)
+
+ useEffect(() => {
+ getJSON('/user/contacts')
+ .then(data => {
+ setData(data.contacts.map(buildContact).sort(alphabetical))
+ })
+ .catch(error => setError(error))
+ .finally(() => setLoading(false))
+ }, [])
+
+ return { loading, data, error }
+}
+
+function buildContact(contact) {
+ const [emailPrefix] = contact.email.split('@')
+
+ // the name is not just the default "email prefix as first name"
+ const hasName = contact.last_name || contact.first_name !== emailPrefix
+
+ const name = hasName
+ ? [contact.first_name, contact.last_name].filter(Boolean).join(' ')
+ : ''
+
+ return {
+ ...contact,
+ name,
+ display: name ? `${name} <${contact.email}>` : contact.email
+ }
+}
diff --git a/services/web/frontend/js/features/share-project-modal/utils/api.js b/services/web/frontend/js/features/share-project-modal/utils/api.js
new file mode 100644
index 0000000000..66446d917a
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/utils/api.js
@@ -0,0 +1,67 @@
+import {
+ deleteJSON,
+ getJSON,
+ postJSON,
+ putJSON
+} from '../../../infrastructure/fetch-json'
+import { executeV2Captcha } from './captcha'
+
+export function sendInvite(project, email, privileges) {
+ return executeV2Captcha(
+ window.ExposedSettings.recaptchaDisabled?.invite
+ ).then(grecaptchaResponse => {
+ return postJSON(`/project/${project._id}/invite`, {
+ body: {
+ email, // TODO: normalisedEmail?
+ privileges,
+ 'g-recaptcha-response': grecaptchaResponse
+ }
+ })
+ })
+}
+
+export function resendInvite(project, invite) {
+ return postJSON(`/project/${project._id}/invite/${invite._id}/resend`)
+}
+
+export function revokeInvite(project, invite) {
+ return deleteJSON(`/project/${project._id}/invite/${invite._id}`)
+}
+
+export function updateMember(project, member, data) {
+ return putJSON(`/project/${project._id}/users/${member._id}`, {
+ body: data
+ })
+}
+
+export function removeMemberFromProject(project, member) {
+ return deleteJSON(`/project/${project._id}/users/${member._id}`)
+}
+
+export function transferProjectOwnership(project, member) {
+ return postJSON(`/project/${project._id}/transfer-ownership`, {
+ body: {
+ user_id: member._id
+ }
+ })
+}
+
+export function setProjectAccessLevel(project, publicAccessLevel) {
+ return postJSON(`/project/${project._id}/settings/admin`, {
+ body: { publicAccessLevel }
+ })
+}
+
+// export function updateProjectAdminSettings(project, data) {
+// return postJSON(`/project/${project._id}/settings/admin`, {
+// body: data
+// })
+// }
+
+export function listProjectMembers(project) {
+ return getJSON(`/project/${project._id}/members`)
+}
+
+export function listProjectInvites(project) {
+ return getJSON(`/project/${project._id}/invites`)
+}
diff --git a/services/web/frontend/js/features/share-project-modal/utils/captcha.js b/services/web/frontend/js/features/share-project-modal/utils/captcha.js
new file mode 100644
index 0000000000..a1b6aaf808
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/utils/captcha.js
@@ -0,0 +1,26 @@
+let _recaptchaId
+let _recaptchaResolve
+export function executeV2Captcha(disabled = false) {
+ return new Promise((resolve, reject) => {
+ if (disabled || !window.grecaptcha) {
+ return resolve()
+ }
+
+ try {
+ if (!_recaptchaId) {
+ _recaptchaId = window.grecaptcha.render('recaptcha', {
+ callback: token => {
+ if (_recaptchaResolve) {
+ _recaptchaResolve(token)
+ _recaptchaResolve = undefined
+ }
+ window.grecaptcha.reset()
+ }
+ })
+ }
+ _recaptchaResolve = resolve
+ } catch (error) {
+ reject(error)
+ }
+ })
+}
diff --git a/services/web/frontend/js/features/share-project-modal/utils/location.js b/services/web/frontend/js/features/share-project-modal/utils/location.js
new file mode 100644
index 0000000000..ce929db19f
--- /dev/null
+++ b/services/web/frontend/js/features/share-project-modal/utils/location.js
@@ -0,0 +1,9 @@
+// window location-related functions in a separate module so they can be mocked/stubbed in tests
+
+export function reload() {
+ window.location.reload()
+}
+
+export function assign(url) {
+ window.location.assign(url)
+}
diff --git a/services/web/frontend/js/ide/share/index.js b/services/web/frontend/js/ide/share/index.js
index 2a336de39c..6ced21a543 100644
--- a/services/web/frontend/js/ide/share/index.js
+++ b/services/web/frontend/js/ide/share/index.js
@@ -4,3 +4,4 @@ import './controllers/ShareProjectModalMemberRowController'
import './controllers/OwnershipTransferConfirmModalController'
import './services/projectMembers'
import './services/projectInvites'
+import '../../features/share-project-modal/controllers/react-share-project-modal-controller'
diff --git a/services/web/frontend/js/shared/components/copy-link.js b/services/web/frontend/js/shared/components/copy-link.js
new file mode 100644
index 0000000000..c7b186fb4e
--- /dev/null
+++ b/services/web/frontend/js/shared/components/copy-link.js
@@ -0,0 +1,49 @@
+import React, { useCallback, useState } from 'react'
+import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'
+import PropTypes from 'prop-types'
+import { Trans } from 'react-i18next'
+import Icon from './icon'
+
+export default function CopyLink({ link, tooltipId }) {
+ const [copied, setCopied] = useState(false)
+
+ const handleClick = useCallback(() => {
+ navigator.clipboard.writeText(link).then(() => {
+ setCopied(true)
+ window.setTimeout(() => {
+ setCopied(false)
+ }, 1500)
+ })
+ }, [link])
+
+ if (!navigator.clipboard?.writeText) {
+ return null
+ }
+
+ return (
+
+ {copied ? 'Copied!' : }
+
+ }
+ >
+
+
+ )
+}
+CopyLink.propTypes = {
+ link: PropTypes.string.isRequired,
+ tooltipId: PropTypes.string.isRequired
+}
diff --git a/services/web/frontend/stories/share-project-modal.stories.js b/services/web/frontend/stories/share-project-modal.stories.js
new file mode 100644
index 0000000000..694b0a55d0
--- /dev/null
+++ b/services/web/frontend/stories/share-project-modal.stories.js
@@ -0,0 +1,241 @@
+import React, { useEffect } from 'react'
+import fetchMock from 'fetch-mock'
+import ShareProjectModal from '../js/features/share-project-modal/components/share-project-modal'
+
+const contacts = [
+ // user with edited name
+ {
+ type: 'user',
+ email: 'test-user@example.com',
+ first_name: 'Test',
+ last_name: 'User',
+ name: 'Test User'
+ },
+ // user with default name (email prefix)
+ {
+ type: 'user',
+ email: 'test@example.com',
+ first_name: 'test'
+ },
+ // no last name
+ {
+ type: 'user',
+ first_name: 'Eratosthenes',
+ email: 'eratosthenes@example.com'
+ },
+ // more users
+ {
+ type: 'user',
+ first_name: 'Claudius',
+ last_name: 'Ptolemy',
+ email: 'ptolemy@example.com'
+ },
+ {
+ type: 'user',
+ first_name: 'Abd al-Rahman',
+ last_name: 'Al-Sufi',
+ email: 'al-sufi@example.com'
+ },
+ {
+ type: 'user',
+ first_name: 'Nicolaus',
+ last_name: 'Copernicus',
+ email: 'copernicus@example.com'
+ }
+]
+
+const setupFetchMock = () => {
+ const delay = 1000
+
+ fetchMock
+ .restore()
+ // list contacts
+ .get('express:/user/contacts', { contacts }, { delay })
+ // change privacy setting
+ .post('express:/project/:projectId/settings/admin', 200, { delay })
+ // update project member (e.g. set privilege level)
+ .put('express:/project/:projectId/users/:userId', 200, { delay })
+ // remove project member
+ .delete('express:/project/:projectId/users/:userId', 200, { delay })
+ // transfer ownership
+ .post('express:/project/:projectId/transfer-ownership', 200, {
+ delay
+ })
+ // send invite
+ .post('express:/project/:projectId/invite', 200, { delay })
+ // delete invite
+ .delete('express:/project/:projectId/invite/:inviteId', 204, {
+ delay
+ })
+ // resend invite
+ .post('express:/project/:projectId/invite/:inviteId/resend', 200, {
+ delay
+ })
+}
+
+export const LinkSharingOff = args => {
+ setupFetchMock()
+
+ const project = {
+ ...args.project,
+ publicAccesLevel: 'private'
+ }
+
+ return
+}
+
+export const LinkSharingOn = args => {
+ setupFetchMock()
+
+ const project = {
+ ...args.project,
+ publicAccesLevel: 'tokenBased'
+ }
+
+ return
+}
+
+export const LinkSharingLoading = args => {
+ setupFetchMock()
+
+ const project = {
+ ...args.project,
+ publicAccesLevel: 'tokenBased',
+ tokens: undefined
+ }
+
+ return
+}
+
+export const NonAdminLinkSharingOff = args => {
+ const project = {
+ ...args.project,
+ publicAccesLevel: 'private'
+ }
+
+ return
+}
+
+export const NonAdminLinkSharingOn = args => {
+ const project = {
+ ...args.project,
+ publicAccesLevel: 'tokenBased'
+ }
+
+ return
+}
+
+export const RestrictedTokenMember = args => {
+ window.isRestrictedTokenMember = true
+
+ useEffect(() => {
+ return () => {
+ window.isRestrictedTokenMember = false
+ }
+ }, [])
+
+ const project = {
+ ...args.project,
+ publicAccesLevel: 'tokenBased'
+ }
+
+ return
+}
+
+export const LegacyLinkSharingReadAndWrite = args => {
+ setupFetchMock()
+
+ const project = {
+ ...args.project,
+ publicAccesLevel: 'readAndWrite'
+ }
+
+ return
+}
+
+export const LegacyLinkSharingReadOnly = args => {
+ setupFetchMock()
+
+ const project = {
+ ...args.project,
+ publicAccesLevel: 'readOnly'
+ }
+
+ return
+}
+
+export const LimitedCollaborators = args => {
+ setupFetchMock()
+
+ const project = {
+ ...args.project,
+ features: {
+ ...args.project.features,
+ collaborators: 3
+ }
+ }
+
+ return
+}
+
+const project = {
+ _id: 'a-project',
+ name: 'A Project',
+ features: {
+ collaborators: -1 // unlimited
+ },
+ publicAccesLevel: 'private',
+ tokens: {
+ readOnly: 'ro-token',
+ readAndWrite: 'rw-token'
+ },
+ owner: {
+ email: 'stories@overleaf.com'
+ },
+ members: [
+ {
+ _id: 'viewer-member',
+ type: 'user',
+ privileges: 'readOnly',
+ name: 'Viewer User',
+ email: 'viewer@example.com'
+ },
+ {
+ _id: 'author-member',
+ type: 'user',
+ privileges: 'readAndWrite',
+ name: 'Author User',
+ email: 'author@example.com'
+ }
+ ],
+ invites: [
+ {
+ _id: 'test-invite-1',
+ privileges: 'readOnly',
+ name: 'Invited Viewer',
+ email: 'invited-viewer@example.com'
+ },
+ {
+ _id: 'test-invite-2',
+ privileges: 'readAndWrite',
+ name: 'Invited Author',
+ email: 'invited-author@example.com'
+ }
+ ]
+}
+
+export default {
+ title: 'Share Project Modal',
+ component: ShareProjectModal,
+ args: {
+ show: true,
+ animation: false,
+ isAdmin: true,
+ user: {},
+ project,
+ updateProject: () => null
+ },
+ argTypes: {
+ handleHide: { action: 'hide' }
+ }
+}
diff --git a/services/web/frontend/stylesheets/app/editor/share.less b/services/web/frontend/stylesheets/app/editor/share.less
index fc63bc283c..d8b35959d4 100644
--- a/services/web/frontend/stylesheets/app/editor/share.less
+++ b/services/web/frontend/stylesheets/app/editor/share.less
@@ -6,6 +6,18 @@
font-size: 1rem;
}
+ .project-member.form-group {
+ margin-bottom: 0;
+ }
+
+ .project-member .form-control-static.text-center {
+ padding-top: 0;
+ }
+
+ .project-member .remove-button {
+ font-size: inherit;
+ }
+
.project-member,
.project-invite,
.public-access-level {
@@ -27,6 +39,9 @@
background-color: @gray-lightest;
border: 1px solid @gray-lighter;
padding: 6px 12px 6px 12px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
}
}
}
@@ -49,6 +64,7 @@
.invite-controls {
.small {
padding: 2px;
+ margin-bottom: 0;
}
padding: @line-height-computed / 2;
background-color: @gray-lightest;
@@ -67,6 +83,25 @@
font-size: 14px;
}
}
+ .add-collaborators-upgrade {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+ }
+
+ .rbt-menu > .dropdown-item {
+ display: block;
+ padding: 0.25rem 1rem;
+ color: #212529;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &:hover {
+ text-decoration: none;
+ background-color: @gray-lightest;
+ }
}
}
.modal-footer-share {
@@ -75,3 +110,7 @@
text-align: left;
}
}
+
+.copy-button:focus-within {
+ outline: none;
+}
diff --git a/services/web/frontend/stylesheets/components/tags-input.less b/services/web/frontend/stylesheets/components/tags-input.less
index 649f8ea87d..f10483022f 100644
--- a/services/web/frontend/stylesheets/components/tags-input.less
+++ b/services/web/frontend/stylesheets/components/tags-input.less
@@ -1,92 +1,106 @@
-tags-input {
+.tags-input {
display: block;
}
-tags-input *,
-tags-input *:before,
-tags-input *:after {
+.tags-input *,
+.tags-input *:before,
+.tags-input *:after {
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
-tags-input .host {
+.tags-input label.small {
+ font-weight: normal;
+}
+.tags-input .host {
position: relative;
height: 100%;
}
-tags-input .host:active {
+.tags-input .host:active {
outline: none;
}
-tags-input .tags {
+.tags-input .tags {
.form-control;
+ border-radius: 3px; /* overriding .form-control */
-moz-appearance: textfield;
-webkit-appearance: textfield;
- padding: 2px 5px;
+ padding: 3px;
overflow: hidden;
word-wrap: break-word;
cursor: text;
background-color: #fff;
height: 100%;
+ display: flex;
+ flex-wrap: wrap;
}
-tags-input .tags.focused {
+.tags-input .tags.focused,
+.tags-input .tags:focus-within {
outline: none;
-webkit-box-shadow: 0 0 3px 1px rgba(5, 139, 242, 0.6);
-moz-box-shadow: 0 0 3px 1px rgba(5, 139, 242, 0.6);
box-shadow: 0 0 3px 1px rgba(5, 139, 242, 0.6);
}
-tags-input .tags .tag-list {
+.tags-input .tags .tag-list {
margin: 0;
padding: 0;
list-style-type: none;
}
-tags-input .tags .tag-item {
+.tags-input .tags .tag-item {
margin: 2px;
- padding: 0 7px;
- display: inline-block;
- float: left;
- height: 26px;
- line-height: 25px;
+ padding: 2px 7px;
+ display: inline-flex;
+ align-items: center;
+ line-height: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
border: 1px solid @gray-light;
background-color: @gray-lightest;
border-radius: 3px;
}
-tags-input .tags .tag-item.selected {
+.tags-input .tags .tag-item.selected {
background-color: @gray-lighter;
}
-tags-input .tags .tag-item .remove-button {
+.tags-input .tags .tag-item .fa {
+ flex-shrink: 0;
+}
+.tags-input .tags .tag-item .remove-button {
color: @gray-light;
text-decoration: none;
+ cursor: pointer;
+ flex-shrink: 0;
}
-tags-input .tags .tag-item .remove-button:active {
+.tags-input .tags .tag-item .remove-button:active {
color: @brand-primary;
}
-tags-input .tags .input {
+.tags-input .tags .input {
border: 0;
outline: none;
margin: 2px;
padding: 0;
padding-left: 5px;
- float: left;
- height: 26px;
+ flex-grow: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
-tags-input .tags .input.invalid-tag {
+.tags-input .tags .input.invalid-tag {
color: @brand-danger;
}
-tags-input .tags .input::-ms-clear {
+.tags-input .tags .input::-ms-clear {
display: none;
}
-tags-input.ng-invalid .tags {
+.tags-input.ng-invalid .tags {
-webkit-box-shadow: 0 0 3px 1px rgba(255, 0, 0, 0.6);
-moz-box-shadow: 0 0 3px 1px rgba(255, 0, 0, 0.6);
box-shadow: 0 0 3px 1px rgba(255, 0, 0, 0.6);
}
-tags-input[disabled] .host:focus {
+.tags-input[disabled] .host:focus {
outline: none;
}
-tags-input[disabled] .tags {
+.tags-input[disabled] .tags {
background-color: #eee;
cursor: default;
}
-tags-input[disabled] .tags .tag-item {
+.tags-input[disabled] .tags .tag-item {
opacity: 0.65;
background: -webkit-linear-gradient(
top,
@@ -101,18 +115,18 @@ tags-input[disabled] .tags .tag-item {
rgba(161, 219, 255, 0.62) 100%
);
}
-tags-input[disabled] .tags .tag-item .remove-button {
+.tags-input[disabled] .tags .tag-item .remove-button {
cursor: default;
}
-tags-input[disabled] .tags .tag-item .remove-button:active {
+.tags-input[disabled] .tags .tag-item .remove-button:active {
color: @brand-primary;
}
-tags-input[disabled] .tags .input {
+.tags-input[disabled] .tags .input {
background-color: #eee;
cursor: default;
}
-tags-input .autocomplete {
+.tags-input .autocomplete {
margin-top: 5px;
position: absolute;
padding: 5px 0;
@@ -124,7 +138,7 @@ tags-input .autocomplete {
-moz-box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
}
-tags-input .autocomplete .suggestion-list {
+.tags-input .autocomplete .suggestion-list {
margin: 0;
padding: 0;
list-style-type: none;
@@ -132,21 +146,21 @@ tags-input .autocomplete .suggestion-list {
overflow-y: auto;
position: relative;
}
-tags-input .autocomplete .suggestion-item {
+.tags-input .autocomplete .suggestion-item {
padding: 5px 10px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
-tags-input .autocomplete .suggestion-item.selected {
+.tags-input .autocomplete .suggestion-item.selected {
color: white;
background-color: @brand-primary;
.subdued {
color: white;
}
}
-tags-input .autocomplete .suggestion-item em {
+.tags-input .autocomplete .suggestion-item em {
font-weight: bold;
font-style: normal;
}
diff --git a/services/web/frontend/stylesheets/core/variables.less b/services/web/frontend/stylesheets/core/variables.less
index 68a502214e..1d4e74914f 100644
--- a/services/web/frontend/stylesheets/core/variables.less
+++ b/services/web/frontend/stylesheets/core/variables.less
@@ -303,11 +303,11 @@
@zindex-navbar: 1000;
@zindex-dropdown: 1000;
-@zindex-popover: 1010;
-@zindex-tooltip: 1030;
@zindex-navbar-fixed: 1030;
@zindex-modal-background: 1040;
@zindex-modal: 1050;
+@zindex-popover: 1060;
+@zindex-tooltip: 1070;
//== Media queries breakpoints
//
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 30ca75804b..1b13ecf229 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -1091,6 +1091,9 @@
"over_x_templates_easy_getting_started": "There are thousands of __templates__ in our template gallery, so it's really easy to get started, whether you're writing a journal article, thesis, CV or something else.",
"done": "Done",
"change": "Change",
+ "change_or_cancel-change": "Change",
+ "change_or_cancel-or": "or",
+ "change_or_cancel-cancel": "cancel",
"page_not_found": "Page Not Found",
"please_see_help_for_more_info": "Please see our help guide for more information",
"this_project_will_appear_in_your_dropbox_folder_at": "This project will appear in your Dropbox folder at ",
diff --git a/services/web/package-lock.json b/services/web/package-lock.json
index 2e1697849c..0010750797 100644
--- a/services/web/package-lock.json
+++ b/services/web/package-lock.json
@@ -12179,10 +12179,9 @@
"integrity": "sha512-+BNfZ+deCo8hMNpDqDnvT+c0XpJ5cUa6mqYq89bho2Ifze4URTqRkcwR399hWoTrTkbZ/XJYDgP6rc7pRgffEQ=="
},
"downshift": {
- "version": "6.0.10",
- "resolved": "https://registry.npmjs.org/downshift/-/downshift-6.0.10.tgz",
- "integrity": "sha512-TuUh448snXiOXrstL1q6s13xev2kWEHAuNlwzEHXRMhG7NbPgvzFvjYelwkaOSZ1dFNJjzRnpK6cbvUO7oHlMQ==",
- "dev": true,
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/downshift/-/downshift-6.1.0.tgz",
+ "integrity": "sha512-MnEJERij+1pTVAsOPsH3q9MJGNIZuu2sT90uxOCEOZYH6sEzkVGtUcTBVDRQkE8y96zpB7uEbRn24aE9VpHnZg==",
"requires": {
"@babel/runtime": "^7.12.5",
"compute-scroll-into-view": "^1.0.16",
@@ -12191,10 +12190,9 @@
},
"dependencies": {
"@babel/runtime": {
- "version": "7.12.5",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz",
- "integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==",
- "dev": true,
+ "version": "7.12.13",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.13.tgz",
+ "integrity": "sha512-8+3UMPBrjFa/6TtKi/7sehPKqfAm4g6K+YQjyyFOLUTxzOngcRZTlAVY8sc2CORJYqdHQY8gRPHmn+qo15rCBw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
@@ -12202,20 +12200,17 @@
"compute-scroll-into-view": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.16.tgz",
- "integrity": "sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ==",
- "dev": true
+ "integrity": "sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ=="
},
"react-is": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.1.tgz",
- "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA==",
- "dev": true
+ "integrity": "sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA=="
},
"regenerator-runtime": {
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
- "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==",
- "dev": true
+ "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
}
}
},
@@ -19922,6 +19917,30 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-0.3.12.tgz",
"integrity": "sha512-k4NaW+vS7ytQn6MgJn3fYpQt20/mOgYM5Ft9BYMfQJDz2QT6yEeS9XJ8k2Nw8JTeWK/znPPW2n3UJGzyYEiMoA=="
},
+ "match-sorter": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.2.0.tgz",
+ "integrity": "sha512-yhmUTR5q6JP/ssR1L1y083Wp+C+TdR8LhYTxWI4IRgEUr8IXJu2mE6L3SwryCgX95/5J7qZdEg0G091sOxr1FQ==",
+ "requires": {
+ "@babel/runtime": "^7.12.5",
+ "remove-accents": "0.4.2"
+ },
+ "dependencies": {
+ "@babel/runtime": {
+ "version": "7.12.13",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.13.tgz",
+ "integrity": "sha512-8+3UMPBrjFa/6TtKi/7sehPKqfAm4g6K+YQjyyFOLUTxzOngcRZTlAVY8sc2CORJYqdHQY8gRPHmn+qo15rCBw==",
+ "requires": {
+ "regenerator-runtime": "^0.13.4"
+ }
+ },
+ "regenerator-runtime": {
+ "version": "0.13.7",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
+ "integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
+ }
+ }
+ },
"material-colors": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz",
@@ -26411,6 +26430,11 @@
"mdast-squeeze-paragraphs": "^4.0.0"
}
},
+ "remove-accents": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
+ "integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U="
+ },
"remove-bom-buffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz",
diff --git a/services/web/package.json b/services/web/package.json
index cc0d68fab3..48addf036b 100644
--- a/services/web/package.json
+++ b/services/web/package.json
@@ -81,6 +81,7 @@
"d3": "^3.5.16",
"dateformat": "1.0.4-1.2.3",
"daterangepicker": "https://github.com/40thieves/daterangepicker/archive/e496d2d44ca53e208c930e4cb4bcf29bcefa4550.tar.gz",
+ "downshift": "^6.1.0",
"east": "^1.1.0",
"express": "4.17.1",
"express-bearer-token": "^2.4.0",
@@ -103,6 +104,7 @@
"logger-sharelatex": "^2.2.0",
"mailchimp-api-v3": "^1.12.0",
"marked": "^0.3.5",
+ "match-sorter": "^6.2.0",
"method-override": "^2.3.3",
"minimist": "1.2.5",
"mmmagic": "^0.5.3",
diff --git a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js
new file mode 100644
index 0000000000..490d6795b8
--- /dev/null
+++ b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.js
@@ -0,0 +1,752 @@
+import { expect } from 'chai'
+import sinon from 'sinon'
+import React from 'react'
+import {
+ act,
+ cleanup,
+ render,
+ screen,
+ fireEvent,
+ waitFor
+} from '@testing-library/react'
+import fetchMock from 'fetch-mock'
+import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
+import * as locationModule from '../../../../../frontend/js/features/share-project-modal/utils/location'
+
+describe('', function() {
+ const project = {
+ _id: 'test-project',
+ name: 'Test Project',
+ features: {
+ collaborators: 10
+ },
+ owner: {
+ email: 'project-owner@example.com'
+ },
+ members: [],
+ invites: []
+ }
+
+ const contacts = [
+ // user with edited name
+ {
+ type: 'user',
+ email: 'test-user@example.com',
+ first_name: 'Test',
+ last_name: 'User',
+ name: 'Test User'
+ },
+ // user with default name (email prefix)
+ {
+ type: 'user',
+ email: 'test@example.com',
+ first_name: 'test'
+ },
+ // no last name
+ {
+ type: 'user',
+ first_name: 'Eratosthenes',
+ email: 'eratosthenes@example.com'
+ },
+ // more users
+ {
+ type: 'user',
+ first_name: 'Claudius',
+ last_name: 'Ptolemy',
+ email: 'ptolemy@example.com'
+ },
+ {
+ type: 'user',
+ first_name: 'Abd al-Rahman',
+ last_name: 'Al-Sufi',
+ email: 'al-sufi@example.com'
+ },
+ {
+ type: 'user',
+ first_name: 'Nicolaus',
+ last_name: 'Copernicus',
+ email: 'copernicus@example.com'
+ }
+ ]
+
+ const modalProps = {
+ show: true,
+ isAdmin: true,
+ project,
+ handleHide: sinon.stub(),
+ updateProject: sinon.stub()
+ }
+
+ const originalExposedSettings = window.ExposedSettings
+
+ before(function() {
+ window.ExposedSettings = { appName: 'Overleaf' }
+ })
+
+ after(function() {
+ window.ExposedSettings = originalExposedSettings
+ })
+
+ beforeEach(function() {
+ fetchMock.get('/user/contacts', { contacts })
+ })
+
+ afterEach(function() {
+ fetchMock.restore()
+ cleanup()
+ })
+
+ it('renders the modal', async function() {
+ render()
+
+ await screen.findByText('Share Project')
+ })
+
+ it('calls handleHide when a Close button is pressed', async function() {
+ const handleHide = sinon.stub()
+
+ render()
+
+ const [
+ headerCloseButton,
+ footerCloseButton
+ ] = await screen.findAllByRole('button', { name: 'Close' })
+
+ fireEvent.click(headerCloseButton)
+ fireEvent.click(footerCloseButton)
+
+ expect(handleHide.callCount).to.equal(2)
+ })
+
+ it('handles access level "private"', async function() {
+ render(
+
+ )
+
+ await screen.findByText(
+ 'Link sharing is off, only invited users can view this project.'
+ )
+ await screen.findByRole('button', { name: 'Turn on link sharing' })
+
+ expect(screen.queryByText('Anyone with this link can view this project')).to
+ .be.null
+ expect(screen.queryByText('Anyone with this link can edit this project')).to
+ .be.null
+ })
+
+ it('handles access level "tokenBased"', async function() {
+ render(
+
+ )
+
+ await screen.findByText('Link sharing is on')
+ await screen.findByRole('button', { name: 'Turn off link sharing' })
+
+ expect(screen.queryByText('Anyone with this link can view this project'))
+ .not.to.be.null
+ expect(screen.queryByText('Anyone with this link can edit this project'))
+ .not.to.be.null
+ })
+
+ it('handles legacy access level "readAndWrite"', async function() {
+ render(
+
+ )
+
+ await screen.findByText(
+ 'This project is public and can be edited by anyone with the URL.'
+ )
+ await screen.findByRole('button', { name: 'Make Private' })
+ })
+
+ it('handles legacy access level "readOnly"', async function() {
+ render(
+
+ )
+
+ await screen.findByText(
+ 'This project is public and can be viewed but not edited by anyone with the URL'
+ )
+ await screen.findByRole('button', { name: 'Make Private' })
+ })
+
+ it('hides actions from non-admins', async function() {
+ const invites = [
+ {
+ _id: 'invited-author',
+ email: 'invited-author@example.com',
+ privileges: 'readAndWrite'
+ }
+ ]
+
+ // render as admin: actions should be present
+ const { rerender } = render(
+
+ )
+
+ await screen.findByRole('button', { name: 'Turn off link sharing' })
+ await screen.findByRole('button', { name: 'Resend' })
+
+ // render as non-admin, link sharing on: actions should be missing and message should be present
+ rerender(
+
+ )
+
+ await screen.findByText(
+ 'To change access permissions, please ask the project owner'
+ )
+
+ expect(screen.queryByRole('button', { name: 'Turn off link sharing' })).to
+ .be.null
+ expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
+ .null
+ expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null
+
+ // render as non-admin, link sharing off: actions should be missing and message should be present
+ rerender(
+
+ )
+
+ await screen.findByText(
+ 'To add more collaborators or turn on link sharing, please ask the project owner'
+ )
+
+ expect(screen.queryByRole('button', { name: 'Turn off link sharing' })).to
+ .be.null
+ expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
+ .null
+ expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null
+ })
+
+ it('only shows read-only token link to restricted token members', async function() {
+ window.isRestrictedTokenMember = true
+
+ render(
+
+ )
+
+ window.isRestrictedTokenMember = false
+
+ // no buttons
+ expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
+ .null
+ expect(screen.queryByRole('button', { name: 'Turn off link sharing' })).to
+ .be.null
+
+ // only read-only token link
+ await screen.findByText('Anyone with this link can view this project')
+ expect(screen.queryByText('Anyone with this link can edit this project')).to
+ .be.null
+ })
+
+ it('displays project members and invites', async function() {
+ const members = [
+ {
+ _id: 'member-author',
+ email: 'member-author@example.com',
+ privileges: 'readAndWrite'
+ },
+ {
+ _id: 'member-viewer',
+ email: 'member-viewer@example.com',
+ privileges: 'readOnly'
+ }
+ ]
+
+ const invites = [
+ {
+ _id: 'invited-author',
+ email: 'invited-author@example.com',
+ privileges: 'readAndWrite'
+ },
+ {
+ _id: 'invited-viewer',
+ email: 'invited-viewer@example.com',
+ privileges: 'readOnly'
+ }
+ ]
+
+ render(
+
+ )
+
+ expect(screen.queryAllByText('project-owner@example.com')).to.have.length(1)
+ expect(screen.queryAllByText('member-author@example.com')).to.have.length(1)
+ expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
+ expect(screen.queryAllByText('invited-author@example.com')).to.have.length(
+ 1
+ )
+ expect(screen.queryAllByText('invited-viewer@example.com')).to.have.length(
+ 1
+ )
+
+ expect(screen.queryAllByText('Invite not yet accepted.')).to.have.length(
+ invites.length
+ )
+ expect(screen.queryAllByRole('button', { name: 'Resend' })).to.have.length(
+ invites.length
+ )
+ })
+
+ it('resends an invite', async function() {
+ fetchMock.postOnce(
+ 'express:/project/:projectId/invite/:inviteId/resend',
+ 204
+ )
+
+ const invites = [
+ {
+ _id: 'invited-author',
+ email: 'invited-author@example.com',
+ privileges: 'readAndWrite'
+ }
+ ]
+
+ render(
+
+ )
+
+ const [, closeButton] = screen.getAllByRole('button', {
+ name: 'Close'
+ })
+
+ const resendButton = screen.getByRole('button', { name: 'Resend' })
+
+ await act(async () => {
+ await fireEvent.click(resendButton)
+ expect(closeButton.disabled).to.be.true
+ })
+
+ expect(fetchMock.done()).to.be.true
+ expect(closeButton.disabled).to.be.false
+ })
+
+ it('revokes an invite', async function() {
+ fetchMock.deleteOnce('express:/project/:projectId/invite/:inviteId', 204)
+
+ const invites = [
+ {
+ _id: 'invited-author',
+ email: 'invited-author@example.com',
+ privileges: 'readAndWrite'
+ }
+ ]
+
+ render(
+
+ )
+
+ const [, closeButton] = screen.getAllByRole('button', {
+ name: 'Close'
+ })
+
+ const revokeButton = screen.getByRole('button', { name: 'Revoke' })
+
+ await act(async () => {
+ await fireEvent.click(revokeButton)
+ expect(closeButton.disabled).to.be.true
+ })
+
+ expect(fetchMock.done()).to.be.true
+ expect(closeButton.disabled).to.be.false
+ })
+
+ it('changes member privileges to read + write', async function() {
+ fetchMock.putOnce('express:/project/:projectId/users/:userId', 204)
+
+ const members = [
+ {
+ _id: 'member-viewer',
+ email: 'member-viewer@example.com',
+ privileges: 'readOnly'
+ }
+ ]
+
+ render(
+
+ )
+
+ const [, closeButton] = await screen.getAllByRole('button', {
+ name: 'Close'
+ })
+
+ expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
+
+ const select = screen.getByDisplayValue('Read Only')
+ await fireEvent.change(select, { target: { value: 'readAndWrite' } })
+
+ const changeButton = screen.getByRole('button', { name: 'Change' })
+
+ await act(async () => {
+ await fireEvent.click(changeButton)
+ expect(closeButton.disabled).to.be.true
+
+ const { body } = fetchMock.lastOptions()
+ expect(JSON.parse(body)).to.deep.equal({ privilegeLevel: 'readAndWrite' })
+ })
+
+ expect(fetchMock.done()).to.be.true
+ expect(closeButton.disabled).to.be.false
+ })
+
+ it('removes a member from the project', async function() {
+ fetchMock.deleteOnce('express:/project/:projectId/users/:userId', 204)
+
+ const members = [
+ {
+ _id: 'member-viewer',
+ email: 'member-viewer@example.com',
+ privileges: 'readOnly'
+ }
+ ]
+
+ render(
+
+ )
+
+ expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
+
+ const removeButton = screen.getByRole('button', {
+ name: 'Remove from project'
+ })
+
+ act(() => {
+ removeButton.click()
+ })
+
+ expect(fetchMock.done()).to.be.true
+
+ // TODO: once the project data is updated, assert that the member has been removed
+ })
+
+ it('changes member privileges to owner with confirmation', async function() {
+ fetchMock.postOnce('express:/project/:projectId/transfer-ownership', 204)
+
+ const members = [
+ {
+ _id: 'member-viewer',
+ email: 'member-viewer@example.com',
+ privileges: 'readOnly'
+ }
+ ]
+
+ render(
+
+ )
+
+ expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
+
+ const select = screen.getByDisplayValue('Read Only')
+ fireEvent.change(select, { target: { value: 'owner' } })
+
+ const changeButton = screen.getByRole('button', { name: 'Change' })
+ await fireEvent.click(changeButton)
+
+ screen.getByText((_, node) => {
+ return (
+ node.textContent ===
+ 'Are you sure you want to make member-viewer@example.com the owner of Test Project?'
+ )
+ })
+
+ const confirmButton = screen.getByRole('button', {
+ name: 'Change owner',
+ hidden: true
+ })
+
+ const reloadStub = sinon.stub(locationModule, 'reload')
+
+ await act(async () => {
+ await fireEvent.click(confirmButton)
+ expect(confirmButton.disabled).to.be.true
+
+ const { body } = fetchMock.lastOptions()
+ expect(JSON.parse(body)).to.deep.equal({ user_id: 'member-viewer' })
+ })
+
+ expect(fetchMock.done()).to.be.true
+ expect(reloadStub.calledOnce).to.be.true
+ reloadStub.restore()
+ })
+
+ it('sends invites to input email addresses', async function() {
+ // TODO: can't use this as the value of useProjectContext doesn't get updated
+ // let mergedProject = {
+ // ...project,
+ // publicAccesLevel: 'tokenBased'
+ // }
+ //
+ // const updateProject = value => {
+ // mergedProject = { ...mergedProject, ...value }
+ //
+ // rerender(
+ //
+ // )
+ // }
+ //
+ // const { rerender } = render(
+ //
+ // )
+
+ render(
+
+ )
+
+ const [inputElement] = await screen.findAllByLabelText(
+ 'Share with your collaborators'
+ )
+
+ // loading contacts
+ await waitFor(() => {
+ expect(fetchMock.called('express:/user/contacts')).to.be.true
+ })
+
+ // displaying a list of matching contacts
+ inputElement.focus()
+ fireEvent.change(inputElement, { target: { value: 'ptolemy' } })
+
+ await screen.findByText(/ptolemy@example.com/)
+
+ // sending invitations
+
+ fetchMock.post('express:/project/:projectId/invite', (url, req) => {
+ const data = JSON.parse(req.body)
+
+ if (data.email === 'a@b.c') {
+ return {
+ status: 400,
+ body: { errorReason: 'invalid_email' }
+ }
+ }
+
+ return {
+ status: 200,
+ body: {
+ invite: {
+ ...data,
+ _id: data.email
+ }
+ }
+ }
+ })
+
+ fireEvent.paste(inputElement, {
+ clipboardData: {
+ getData: () =>
+ 'test@example.com, foo@example.com, bar@example.com, a@b.c'
+ }
+ })
+
+ const privilegesElement = screen.getByDisplayValue('Can Edit')
+ fireEvent.change(privilegesElement, { target: { value: 'readOnly' } })
+
+ const submitButton = screen.getByRole('button', { name: 'Share' })
+ submitButton.click()
+
+ let calls
+ await waitFor(
+ () => {
+ calls = fetchMock.calls('express:/project/:projectId/invite')
+ expect(calls).to.have.length(4)
+ },
+ { timeout: 5000 } // allow time for delay between each request
+ )
+
+ expect(calls[0][1].body).to.equal(
+ JSON.stringify({ email: 'test@example.com', privileges: 'readOnly' })
+ )
+ expect(calls[1][1].body).to.equal(
+ JSON.stringify({ email: 'foo@example.com', privileges: 'readOnly' })
+ )
+ expect(calls[2][1].body).to.equal(
+ JSON.stringify({ email: 'bar@example.com', privileges: 'readOnly' })
+ )
+ expect(calls[3][1].body).to.equal(
+ JSON.stringify({ email: 'a@b.c', privileges: 'readOnly' })
+ )
+
+ // error from the last invite
+ screen.getByText('An email address is invalid')
+ })
+
+ it('displays a message when the collaborator limit is reached', async function() {
+ const originalUser = window.user
+
+ window.user = { allowedFreeTrial: true }
+
+ render(
+
+ )
+
+ expect(screen.queryByLabelText('Share with your collaborators')).to.be.null
+
+ screen.getByText(
+ /You need to upgrade your account to add more collaborators/
+ )
+
+ window.user = originalUser
+ })
+
+ it('handles server error responses', async function() {
+ render(
+
+ )
+
+ // loading contacts
+ await waitFor(() => {
+ expect(fetchMock.called('express:/user/contacts')).to.be.true
+ })
+
+ const [inputElement] = await screen.findAllByLabelText(
+ 'Share with your collaborators'
+ )
+
+ const submitButton = screen.getByRole('button', { name: 'Share' })
+
+ const respondWithError = async function(errorReason) {
+ await act(async () => {
+ inputElement.focus()
+ await fireEvent.change(inputElement, {
+ target: { value: 'invited-author-1@example.com' }
+ })
+ inputElement.blur()
+ })
+
+ fetchMock.postOnce(
+ 'express:/project/:projectId/invite',
+ {
+ status: 400,
+ body: { errorReason }
+ },
+ { overwriteRoutes: true }
+ )
+
+ expect(submitButton.disabled).to.be.false
+ submitButton.click()
+ await fetchMock.flush(true)
+ expect(fetchMock.done()).to.be.true
+ }
+
+ await respondWithError('cannot_invite_non_user')
+ await screen.findByText(
+ `Can't send invite. Recipient must already have a Overleaf account`
+ )
+
+ await respondWithError('cannot_verify_user_not_robot')
+ await screen.findByText(
+ `Sorry, we could not verify that you are not a robot. Please check that Google reCAPTCHA is not being blocked by an ad blocker or firewall.`
+ )
+
+ await respondWithError('cannot_invite_self')
+ await screen.findByText(`Can't send invite to yourself`)
+
+ await respondWithError('invalid_email')
+ await screen.findByText(`An email address is invalid`)
+
+ await respondWithError('too_many_requests')
+ await screen.findByText(
+ `Too many requests were received in a short space of time. Please wait for a few moments and try again.`
+ )
+ })
+
+ // TODO: add test for switching between token-access and private, once project data is in React context
+})