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 ( +
+ + + + + +
+ setPrivileges(event.target.value)} + > + + + +    + +
+
+
+ ) +} 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 ( +
+ + + {member.email} + + + + setPrivileges(event.target.value)} + /> + + + + {privileges === member.privileges ? ( + + ) : ( + setPrivileges(member.privileges)} + /> + )} + + +
+ ) +} +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 +})