mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-29 03:43:39 -05:00
Merge pull request #3636 from overleaf/ae-share-modal-split
Migrate Share modal to React GitOrigin-RevId: 96a52df6ffd751cfcca2bbb68eb9e7a6dc31ff28
This commit is contained in:
parent
a555f0d309
commit
d9576189a3
35 changed files with 3093 additions and 55 deletions
|
@ -1,3 +1,7 @@
|
|||
.sb-show-main.modal-open {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.sb-show-main .modal-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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 (
|
||||
<div className="add-collaborators-upgrade">
|
||||
<p className="text-center">
|
||||
<Trans i18nKey="need_to_upgrade_for_more_collabs" />. Also:
|
||||
</p>
|
||||
|
||||
<ul className="list-unstyled">
|
||||
<li>
|
||||
<Icon type="check" />
|
||||
|
||||
<Trans i18nKey="unlimited_projects" />
|
||||
</li>
|
||||
<li>
|
||||
<Icon type="check" />
|
||||
|
||||
<Trans
|
||||
i18nKey="collabs_per_proj"
|
||||
values={{ collabcount: 'Multiple' }}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<Icon type="check" />
|
||||
|
||||
<Trans i18nKey="full_doc_history" />
|
||||
</li>
|
||||
<li>
|
||||
<Icon type="check" />
|
||||
|
||||
<Trans i18nKey="sync_to_dropbox" />
|
||||
</li>
|
||||
<li>
|
||||
<Icon type="check" />
|
||||
|
||||
<Trans i18nKey="sync_to_github" />
|
||||
</li>
|
||||
<li>
|
||||
<Icon type="check" />
|
||||
|
||||
<Trans i18nKey="compile_larger_projects" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="text-center row-spaced-thin">
|
||||
{window.user.allowedFreeTrial ? (
|
||||
<Button
|
||||
bsStyle="success"
|
||||
onClick={() => {
|
||||
startFreeTrial(
|
||||
'projectMembers',
|
||||
undefined,
|
||||
undefined,
|
||||
eventTracking
|
||||
)
|
||||
setStartedFreeTrial(true)
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="start_free_trial" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
bsStyle="success"
|
||||
onClick={() => {
|
||||
upgradePlan('projectMembers')
|
||||
setStartedFreeTrial(true)
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="start_free_trial" />
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
{startedFreeTrial && (
|
||||
<p className="small">
|
||||
<Trans i18nKey="refresh_page_after_starting_free_trial" />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -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 (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<FormGroup>
|
||||
<SelectCollaborators
|
||||
loading={!nonMemberContacts}
|
||||
options={nonMemberContacts || []}
|
||||
placeholder="joe@example.com, sue@example.com, …"
|
||||
multipleSelectionProps={multipleSelectionProps}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<div className="pull-right">
|
||||
<FormControl
|
||||
componentClass="select"
|
||||
className="privileges"
|
||||
bsSize="sm"
|
||||
value={privileges}
|
||||
onChange={event => setPrivileges(event.target.value)}
|
||||
>
|
||||
<option value="readAndWrite">{t('can_edit')}</option>
|
||||
<option value="readOnly">{t('read_only')}</option>
|
||||
</FormControl>
|
||||
<span> </span>
|
||||
<Button type="submit" bsStyle="info">
|
||||
<Trans i18nKey="share" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
)
|
||||
}
|
|
@ -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 (
|
||||
<TransferOwnershipModal
|
||||
member={member}
|
||||
cancel={() => setConfirmingOwnershipTransfer(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form horizontal id="share-project-form" onSubmit={handleSubmit}>
|
||||
<FormGroup className="project-member">
|
||||
<Col xs={7}>
|
||||
<FormControl.Static>{member.email}</FormControl.Static>
|
||||
</Col>
|
||||
|
||||
<Col xs={3}>
|
||||
<SelectPrivilege
|
||||
value={privileges}
|
||||
handleChange={event => setPrivileges(event.target.value)}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col xs={2}>
|
||||
{privileges === member.privileges ? (
|
||||
<RemoveMemberAction member={member} />
|
||||
) : (
|
||||
<ChangePrivilegesActions
|
||||
handleReset={() => setPrivileges(member.privileges)}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
EditMember.propTypes = {
|
||||
member: PropTypes.shape({
|
||||
_id: PropTypes.string.isRequired,
|
||||
email: PropTypes.string.isRequired,
|
||||
privileges: PropTypes.string.isRequired
|
||||
})
|
||||
}
|
||||
|
||||
function SelectPrivilege({ value, handleChange }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
componentClass="select"
|
||||
className="privileges"
|
||||
bsSize="sm"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="owner">{t('owner')}</option>
|
||||
<option value="readAndWrite">{t('can_edit')}</option>
|
||||
<option value="readOnly">{t('read_only')}</option>
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<FormControl.Static className="text-center">
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={
|
||||
<Tooltip id="tooltip-remove-collaborator">
|
||||
<Trans i18nKey="remove_collaborator" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
bsStyle="link"
|
||||
onClick={handleClick}
|
||||
className="remove-button"
|
||||
aria-label="Remove from project"
|
||||
>
|
||||
<Icon type="times" />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
</FormControl.Static>
|
||||
)
|
||||
}
|
||||
RemoveMemberAction.propTypes = {
|
||||
member: PropTypes.shape({
|
||||
_id: PropTypes.string.isRequired,
|
||||
email: PropTypes.string.isRequired,
|
||||
privileges: PropTypes.string.isRequired
|
||||
})
|
||||
}
|
||||
|
||||
function ChangePrivilegesActions({ handleReset }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<Button type="submit" bsSize="sm" bsStyle="success">
|
||||
<Trans i18nKey="change_or_cancel-change" />
|
||||
</Button>
|
||||
<div className="text-sm">
|
||||
<Trans i18nKey="change_or_cancel-or" />
|
||||
|
||||
<Button type="button" className="btn-inline-link" onClick={handleReset}>
|
||||
<Trans i18nKey="change_or_cancel-cancel" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
ChangePrivilegesActions.propTypes = {
|
||||
handleReset: PropTypes.func.isRequired
|
||||
}
|
|
@ -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 (
|
||||
<Row className="project-invite">
|
||||
<Col xs={7}>
|
||||
<div>{invite.email}</div>
|
||||
|
||||
<div className="small">
|
||||
<Trans i18nKey="invite_not_accepted" />
|
||||
.
|
||||
{isAdmin && <ResendInvite invite={invite} />}
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
<Col xs={3} className="text-left">
|
||||
<MemberPrivileges privileges={invite.privileges} />
|
||||
</Col>
|
||||
|
||||
{isAdmin && (
|
||||
<Col xs={2} className="text-center">
|
||||
<RevokeInvite invite={invite} />
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Button
|
||||
bsStyle="link"
|
||||
className="btn-inline-link"
|
||||
onClick={handleClick}
|
||||
// ref={buttonRef}
|
||||
>
|
||||
<Trans i18nKey="resend" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={
|
||||
<Tooltip id="tooltip-revoke-invite">
|
||||
<Trans i18nKey="revoke_invite" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
bsStyle="link"
|
||||
onClick={handleClick}
|
||||
aria-label="Revoke"
|
||||
className="btn-inline-link"
|
||||
>
|
||||
<Icon type="times" />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
)
|
||||
}
|
||||
RevokeInvite.propTypes = {
|
||||
invite: PropTypes.object.isRequired
|
||||
}
|
|
@ -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 (
|
||||
<PrivateSharing setAccessLevel={setAccessLevel} inflight={inflight} />
|
||||
)
|
||||
|
||||
// Token-based access
|
||||
case 'tokenBased':
|
||||
return (
|
||||
<TokenBasedSharing
|
||||
setAccessLevel={setAccessLevel}
|
||||
inflight={inflight}
|
||||
/>
|
||||
)
|
||||
|
||||
// Legacy public-access
|
||||
case 'readAndWrite':
|
||||
case 'readOnly':
|
||||
return (
|
||||
<LegacySharing
|
||||
setAccessLevel={setAccessLevel}
|
||||
accessLevel={project.publicAccesLevel}
|
||||
inflight={inflight}
|
||||
/>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function PrivateSharing({ setAccessLevel, inflight }) {
|
||||
return (
|
||||
<Row className="public-access-level">
|
||||
<Col xs={12} className="text-center">
|
||||
<Trans i18nKey="link_sharing_is_off" />
|
||||
<span> </span>
|
||||
<Button
|
||||
type="button"
|
||||
bsStyle="link"
|
||||
className="btn-inline-link"
|
||||
onClick={() => setAccessLevel('tokenBased')}
|
||||
disabled={inflight}
|
||||
>
|
||||
<Trans i18nKey="turn_on_link_sharing" />
|
||||
</Button>
|
||||
<span> </span>
|
||||
<LinkSharingInfo />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
PrivateSharing.propTypes = {
|
||||
setAccessLevel: PropTypes.func.isRequired,
|
||||
inflight: PropTypes.bool
|
||||
}
|
||||
|
||||
function TokenBasedSharing({ setAccessLevel, inflight }) {
|
||||
const project = useProjectContext()
|
||||
|
||||
return (
|
||||
<Row className="public-access-level">
|
||||
<Col xs={12} className="text-center">
|
||||
<strong>
|
||||
<Trans i18nKey="link_sharing_is_on" />
|
||||
</strong>
|
||||
<span> </span>
|
||||
<Button
|
||||
bsStyle="link"
|
||||
className="btn-inline-link"
|
||||
onClick={() => setAccessLevel('private')}
|
||||
disabled={inflight}
|
||||
>
|
||||
<Trans i18nKey="turn_off_link_sharing" />
|
||||
</Button>
|
||||
<span> </span>
|
||||
<LinkSharingInfo />
|
||||
</Col>
|
||||
<Col xs={12} className="access-token-display-area">
|
||||
<div className="access-token-wrapper">
|
||||
<strong>
|
||||
<Trans i18nKey="anyone_with_link_can_edit" />
|
||||
</strong>
|
||||
<AccessToken
|
||||
token={project?.tokens?.readAndWrite}
|
||||
path="/"
|
||||
tooltipId="tooltip-copy-link-rw"
|
||||
/>
|
||||
</div>
|
||||
<div className="access-token-wrapper">
|
||||
<strong>
|
||||
<Trans i18nKey="anyone_with_link_can_view" />
|
||||
</strong>
|
||||
<AccessToken
|
||||
token={project?.tokens?.readOnly}
|
||||
path="/read/"
|
||||
tooltipId="tooltip-copy-link-ro"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
TokenBasedSharing.propTypes = {
|
||||
setAccessLevel: PropTypes.func.isRequired,
|
||||
inflight: PropTypes.bool
|
||||
}
|
||||
|
||||
function LegacySharing({ accessLevel, setAccessLevel, inflight }) {
|
||||
return (
|
||||
<Row className="public-access-level">
|
||||
<Col xs={12} className="text-center">
|
||||
<strong>
|
||||
{accessLevel === 'readAndWrite' && (
|
||||
<Trans i18nKey="this_project_is_public" />
|
||||
)}
|
||||
{accessLevel === 'readOnly' && (
|
||||
<Trans i18nKey="this_project_is_public_read_only" />
|
||||
)}
|
||||
</strong>
|
||||
<span> </span>
|
||||
<Button
|
||||
type="button"
|
||||
bsStyle="link"
|
||||
className="btn-inline-link"
|
||||
onClick={() => setAccessLevel('private')}
|
||||
disabled={inflight}
|
||||
>
|
||||
<Trans i18nKey="make_private" />
|
||||
</Button>
|
||||
<span> </span>
|
||||
<LinkSharingInfo />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
LegacySharing.propTypes = {
|
||||
accessLevel: PropTypes.string.isRequired,
|
||||
setAccessLevel: PropTypes.func.isRequired,
|
||||
inflight: PropTypes.bool
|
||||
}
|
||||
|
||||
export function ReadOnlyTokenLink() {
|
||||
const project = useProjectContext()
|
||||
|
||||
return (
|
||||
<Row className="public-access-level">
|
||||
<Col xs={12} className="access-token-display-area">
|
||||
<div className="access-token-wrapper">
|
||||
<strong>
|
||||
<Trans i18nKey="anyone_with_link_can_view" />
|
||||
</strong>
|
||||
<AccessToken
|
||||
token={project?.tokens?.readOnly}
|
||||
path="/read/"
|
||||
tooltipId="tooltip-copy-link-ro"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
function AccessToken({ token, path, tooltipId }) {
|
||||
if (!token) {
|
||||
return (
|
||||
<pre className="access-token">
|
||||
<span>
|
||||
<Trans i18nKey="loading" />…
|
||||
</span>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
const link = `${window.location.origin}${path}${token}`
|
||||
|
||||
return (
|
||||
<pre className="access-token">
|
||||
<span>{link}</span>
|
||||
<CopyLink link={link} tooltipId={tooltipId} />
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
AccessToken.propTypes = {
|
||||
token: PropTypes.string,
|
||||
tooltipId: PropTypes.string.isRequired,
|
||||
path: PropTypes.string.isRequired
|
||||
}
|
||||
|
||||
function LinkSharingInfo() {
|
||||
return (
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={
|
||||
<Tooltip id="tooltip-link-sharing-info">
|
||||
<Trans i18nKey="learn_more_about_link_sharing" />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="/learn/how-to/What_is_Link_Sharing%3F"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<Icon type="question-circle" />
|
||||
</a>
|
||||
</OverlayTrigger>
|
||||
)
|
||||
}
|
|
@ -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 <Trans i18nKey="can_edit" />
|
||||
|
||||
case 'readOnly':
|
||||
return <Trans i18nKey="read_only" />
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
MemberPrivileges.propTypes = {
|
||||
privileges: PropTypes.string.isRequired
|
||||
}
|
|
@ -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 (
|
||||
<Row className="project-member">
|
||||
<Col xs={7}>{project.owner?.email}</Col>
|
||||
<Col xs={3} className="text-left">
|
||||
<Trans i18nKey="owner" />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
|
@ -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 (
|
||||
<div className="tags-input">
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-for */}
|
||||
<label className="small" {...getLabelProps()}>
|
||||
<Trans i18nKey="share_with_your_collabs" />
|
||||
|
||||
{loading && <Icon type="refresh" spin />}
|
||||
</label>
|
||||
|
||||
<div className="host">
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
|
||||
<div {...getComboboxProps()} className="tags" onClick={focusInput}>
|
||||
{selectedItems.map((selectedItem, index) => (
|
||||
<SelectedItem
|
||||
key={`selected-item-${index}`}
|
||||
removeSelectedItem={removeSelectedItem}
|
||||
selectedItem={selectedItem}
|
||||
focusInput={focusInput}
|
||||
index={index}
|
||||
getSelectedItemProps={getSelectedItemProps}
|
||||
/>
|
||||
))}
|
||||
|
||||
<input
|
||||
{...getInputProps(
|
||||
getDropdownProps({
|
||||
className: classnames({
|
||||
input: true,
|
||||
'invalid-tag': !isValidInput
|
||||
}),
|
||||
type: 'email',
|
||||
placeholder,
|
||||
size: inputValue.length
|
||||
? inputValue.length + 5
|
||||
: placeholder.length,
|
||||
ref: inputRef,
|
||||
// preventKeyAction: showDropdown,
|
||||
onBlur: () => {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={classnames({ autocomplete: showDropdownItems })}>
|
||||
<ul {...getMenuProps()} className="suggestion-list">
|
||||
{showDropdownItems &&
|
||||
filteredOptions.map((item, index) => (
|
||||
<Option
|
||||
key={item.email}
|
||||
index={index}
|
||||
item={item}
|
||||
selected={index === highlightedIndex}
|
||||
getItemProps={getItemProps}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
SelectCollaborators.propTypes = {
|
||||
loading: PropTypes.bool.isRequired,
|
||||
options: PropTypes.array.isRequired,
|
||||
placeholder: PropTypes.string,
|
||||
multipleSelectionProps: PropTypes.shape({
|
||||
getSelectedItemProps: PropTypes.func.isRequired,
|
||||
getDropdownProps: PropTypes.func.isRequired,
|
||||
addSelectedItem: PropTypes.func.isRequired,
|
||||
removeSelectedItem: PropTypes.func.isRequired,
|
||||
selectedItems: PropTypes.array.isRequired
|
||||
}).isRequired
|
||||
}
|
||||
|
||||
function Option({ selected, item, getItemProps, index }) {
|
||||
return (
|
||||
<li
|
||||
className={classnames('suggestion-item', { selected })}
|
||||
{...getItemProps({ item, index })}
|
||||
>
|
||||
<Icon type="user" modifier="fw" />
|
||||
|
||||
{item.display}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
Option.propTypes = {
|
||||
selected: PropTypes.bool.isRequired,
|
||||
item: PropTypes.shape({
|
||||
display: PropTypes.string.isRequired
|
||||
}),
|
||||
index: PropTypes.number.isRequired,
|
||||
getItemProps: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
function SelectedItem({
|
||||
removeSelectedItem,
|
||||
selectedItem,
|
||||
focusInput,
|
||||
getSelectedItemProps,
|
||||
index
|
||||
}) {
|
||||
const handleClick = useCallback(
|
||||
event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
removeSelectedItem(selectedItem)
|
||||
focusInput()
|
||||
},
|
||||
[focusInput, removeSelectedItem, selectedItem]
|
||||
)
|
||||
|
||||
return (
|
||||
<span
|
||||
className="tag-item"
|
||||
{...getSelectedItemProps({ selectedItem, index })}
|
||||
>
|
||||
<Icon type="user" modifier="fw" />
|
||||
<span>{selectedItem.display}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="remove-button btn-inline-link"
|
||||
aria-label="Remove"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Icon type="close" modifier="fw" />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
SelectedItem.propTypes = {
|
||||
focusInput: PropTypes.func.isRequired,
|
||||
removeSelectedItem: PropTypes.func.isRequired,
|
||||
selectedItem: PropTypes.shape({
|
||||
display: PropTypes.string.isRequired
|
||||
}),
|
||||
getSelectedItemProps: PropTypes.func.isRequired,
|
||||
index: PropTypes.number.isRequired
|
||||
}
|
|
@ -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 (
|
||||
<Row className="public-access-level public-access-level--notice">
|
||||
<Col xs={12} className="text-center">
|
||||
<AccessLevel level={project.publicAccesLevel} />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
function AccessLevel({ level }) {
|
||||
switch (level) {
|
||||
case 'private':
|
||||
return <Trans i18nKey="to_add_more_collaborators" />
|
||||
|
||||
case 'tokenBased':
|
||||
return <Trans i18nKey="to_change_access_permissions" />
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
AccessLevel.propTypes = {
|
||||
level: PropTypes.string
|
||||
}
|
|
@ -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 (
|
||||
<Row className="invite-controls">
|
||||
{canAddCollaborators ? <AddCollaborators /> : <AddCollaboratorsUpgrade />}
|
||||
</Row>
|
||||
)
|
||||
}
|
|
@ -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 && <LinkSharing />}
|
||||
|
||||
<OwnerInfo />
|
||||
|
||||
{project.members.map(member =>
|
||||
isAdmin ? (
|
||||
<EditMember key={member._id} member={member} />
|
||||
) : (
|
||||
<ViewMember key={member._id} member={member} />
|
||||
)
|
||||
)}
|
||||
|
||||
{project.invites.map(invite => (
|
||||
<Invite key={invite._id} invite={invite} isAdmin={isAdmin} />
|
||||
))}
|
||||
|
||||
{isAdmin ? <SendInvites /> : <SendInvitesNotice />}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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 (
|
||||
<AccessibleModal show={show} onHide={cancel} animation={animation}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
<Trans i18nKey="share_project" />
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body className="modal-body-share">
|
||||
<Grid fluid>
|
||||
{window.isRestrictedTokenMember ? (
|
||||
<ReadOnlyTokenLink />
|
||||
) : (
|
||||
<ShareModalBody />
|
||||
)}
|
||||
</Grid>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer className="modal-footer-share">
|
||||
<div className="modal-footer-left">
|
||||
{inFlight && <Icon type="refresh" spin />}
|
||||
{error && (
|
||||
<span className="text-danger error">
|
||||
<ErrorMessage error={error} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer-right">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
bsStyle="default"
|
||||
disabled={inFlight}
|
||||
>
|
||||
<Trans i18nKey="close" />
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</AccessibleModal>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<Trans
|
||||
i18nKey="cannot_invite_non_user"
|
||||
values={{ appName: window.ExposedSettings.appName }}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'cannot_verify_user_not_robot':
|
||||
return <Trans i18nKey="cannot_verify_user_not_robot" />
|
||||
|
||||
case 'cannot_invite_self':
|
||||
return <Trans i18nKey="cannot_invite_self" />
|
||||
|
||||
case 'invalid_email':
|
||||
return <Trans i18nKey="invalid_email" />
|
||||
|
||||
case 'too_many_requests':
|
||||
return <Trans i18nKey="too_many_requests" />
|
||||
|
||||
default:
|
||||
return <Trans i18nKey="generic_something_went_wrong" />
|
||||
}
|
||||
}
|
||||
ErrorMessage.propTypes = {
|
||||
error: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]).isRequired
|
||||
}
|
|
@ -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 (
|
||||
<ShareProjectContext.Provider
|
||||
value={{
|
||||
isAdmin,
|
||||
updateProject,
|
||||
eventTracking,
|
||||
monitorRequest,
|
||||
inFlight,
|
||||
setInFlight,
|
||||
error,
|
||||
setError
|
||||
}}
|
||||
>
|
||||
<ProjectContext.Provider value={project}>
|
||||
<ShareProjectModalContent
|
||||
animation={animation}
|
||||
cancel={cancel}
|
||||
error={error}
|
||||
inFlight={inFlight}
|
||||
show={show}
|
||||
/>
|
||||
</ProjectContext.Provider>
|
||||
</ShareProjectContext.Provider>
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
|
@ -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 (
|
||||
<AccessibleModal show onHide={cancel}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
<Trans i18nKey="change_project_owner" />
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="project_ownership_transfer_confirmation_1"
|
||||
values={{ user: member.email, project: project.name }}
|
||||
components={[<strong key="strong-1" />, <strong key="strong-2" />]}
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<Trans i18nKey="project_ownership_transfer_confirmation_2" />
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<div className="modal-footer-left">
|
||||
{inflight && <Icon type="refresh" spin />}
|
||||
{error && (
|
||||
<span className="text-danger">
|
||||
<Trans i18nKey="generic_something_went_wrong" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer-right">
|
||||
<Button
|
||||
type="button"
|
||||
bsStyle="default"
|
||||
onClick={cancel}
|
||||
disabled={inflight}
|
||||
>
|
||||
<Trans i18nKey="cancel" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
bsStyle="success"
|
||||
onClick={confirm}
|
||||
disabled={inflight}
|
||||
>
|
||||
<Trans i18nKey="change_owner" />
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</AccessibleModal>
|
||||
)
|
||||
}
|
||||
TransferOwnershipModal.propTypes = {
|
||||
member: PropTypes.object.isRequired,
|
||||
cancel: PropTypes.func.isRequired
|
||||
}
|
|
@ -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 (
|
||||
<Row className="project-member">
|
||||
<Col xs={7}>{member.email}</Col>
|
||||
<Col xs={3}>
|
||||
<MemberPrivileges privileges={member.privileges} />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
ViewMember.propTypes = {
|
||||
member: PropTypes.shape({
|
||||
_id: PropTypes.string.isRequired,
|
||||
email: PropTypes.string.isRequired,
|
||||
privileges: PropTypes.string.isRequired
|
||||
}).isRequired
|
||||
}
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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`)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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'
|
||||
|
|
49
services/web/frontend/js/shared/components/copy-link.js
Normal file
49
services/web/frontend/js/shared/components/copy-link.js
Normal file
|
@ -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 (
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
delayHide={copied ? 1000 : 250}
|
||||
shouldUpdatePosition
|
||||
overlay={
|
||||
<Tooltip id={tooltipId}>
|
||||
{copied ? 'Copied!' : <Trans i18nKey="copy" />}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
bsSize="xsmall"
|
||||
bsStyle="link"
|
||||
className="copy-button"
|
||||
aria-label="Copy"
|
||||
>
|
||||
{copied ? <Icon type="check" /> : <Icon type="clipboard" />}
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
)
|
||||
}
|
||||
CopyLink.propTypes = {
|
||||
link: PropTypes.string.isRequired,
|
||||
tooltipId: PropTypes.string.isRequired
|
||||
}
|
241
services/web/frontend/stories/share-project-modal.stories.js
Normal file
241
services/web/frontend/stories/share-project-modal.stories.js
Normal file
|
@ -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 <ShareProjectModal {...args} project={project} />
|
||||
}
|
||||
|
||||
export const LinkSharingOn = args => {
|
||||
setupFetchMock()
|
||||
|
||||
const project = {
|
||||
...args.project,
|
||||
publicAccesLevel: 'tokenBased'
|
||||
}
|
||||
|
||||
return <ShareProjectModal {...args} project={project} />
|
||||
}
|
||||
|
||||
export const LinkSharingLoading = args => {
|
||||
setupFetchMock()
|
||||
|
||||
const project = {
|
||||
...args.project,
|
||||
publicAccesLevel: 'tokenBased',
|
||||
tokens: undefined
|
||||
}
|
||||
|
||||
return <ShareProjectModal {...args} project={project} />
|
||||
}
|
||||
|
||||
export const NonAdminLinkSharingOff = args => {
|
||||
const project = {
|
||||
...args.project,
|
||||
publicAccesLevel: 'private'
|
||||
}
|
||||
|
||||
return <ShareProjectModal {...args} isAdmin={false} project={project} />
|
||||
}
|
||||
|
||||
export const NonAdminLinkSharingOn = args => {
|
||||
const project = {
|
||||
...args.project,
|
||||
publicAccesLevel: 'tokenBased'
|
||||
}
|
||||
|
||||
return <ShareProjectModal {...args} isAdmin={false} project={project} />
|
||||
}
|
||||
|
||||
export const RestrictedTokenMember = args => {
|
||||
window.isRestrictedTokenMember = true
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.isRestrictedTokenMember = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const project = {
|
||||
...args.project,
|
||||
publicAccesLevel: 'tokenBased'
|
||||
}
|
||||
|
||||
return <ShareProjectModal {...args} project={project} />
|
||||
}
|
||||
|
||||
export const LegacyLinkSharingReadAndWrite = args => {
|
||||
setupFetchMock()
|
||||
|
||||
const project = {
|
||||
...args.project,
|
||||
publicAccesLevel: 'readAndWrite'
|
||||
}
|
||||
|
||||
return <ShareProjectModal {...args} project={project} />
|
||||
}
|
||||
|
||||
export const LegacyLinkSharingReadOnly = args => {
|
||||
setupFetchMock()
|
||||
|
||||
const project = {
|
||||
...args.project,
|
||||
publicAccesLevel: 'readOnly'
|
||||
}
|
||||
|
||||
return <ShareProjectModal {...args} project={project} />
|
||||
}
|
||||
|
||||
export const LimitedCollaborators = args => {
|
||||
setupFetchMock()
|
||||
|
||||
const project = {
|
||||
...args.project,
|
||||
features: {
|
||||
...args.project.features,
|
||||
collaborators: 3
|
||||
}
|
||||
}
|
||||
|
||||
return <ShareProjectModal {...args} project={project} />
|
||||
}
|
||||
|
||||
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' }
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
//
|
||||
|
|
|
@ -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 ",
|
||||
|
|
52
services/web/package-lock.json
generated
52
services/web/package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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('<ShareProjectModal/>', 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(<ShareProjectModal {...modalProps} />)
|
||||
|
||||
await screen.findByText('Share Project')
|
||||
})
|
||||
|
||||
it('calls handleHide when a Close button is pressed', async function() {
|
||||
const handleHide = sinon.stub()
|
||||
|
||||
render(<ShareProjectModal {...modalProps} handleHide={handleHide} />)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{ ...project, publicAccesLevel: 'private' }}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{ ...project, publicAccesLevel: 'tokenBased' }}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{ ...project, publicAccesLevel: 'readAndWrite' }}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{ ...project, publicAccesLevel: 'readOnly' }}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{ ...project, invites, publicAccesLevel: 'tokenBased' }}
|
||||
isAdmin
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{ ...project, invites, publicAccesLevel: 'tokenBased' }}
|
||||
isAdmin={false}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{ ...project, invites, publicAccesLevel: 'private' }}
|
||||
isAdmin={false}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{ ...project, publicAccesLevel: 'tokenBased' }}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{
|
||||
...project,
|
||||
members,
|
||||
invites,
|
||||
publicAccesLevel: 'tokenBased'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{
|
||||
...project,
|
||||
invites,
|
||||
publicAccesLevel: 'tokenBased'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{
|
||||
...project,
|
||||
invites,
|
||||
publicAccesLevel: 'tokenBased'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{
|
||||
...project,
|
||||
members,
|
||||
publicAccesLevel: 'tokenBased'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{
|
||||
...project,
|
||||
members,
|
||||
publicAccesLevel: 'tokenBased'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{
|
||||
...project,
|
||||
members,
|
||||
publicAccesLevel: 'tokenBased'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
// <ShareProjectModal
|
||||
// {...modalProps}
|
||||
// project={mergedProject}
|
||||
// updateProject={updateProject}
|
||||
// />
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// const { rerender } = render(
|
||||
// <ShareProjectModal
|
||||
// {...modalProps}
|
||||
// project={mergedProject}
|
||||
// updateProject={updateProject}
|
||||
// />
|
||||
// )
|
||||
|
||||
render(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{
|
||||
...project,
|
||||
publicAccesLevel: 'tokenBased'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{
|
||||
...project,
|
||||
publicAccesLevel: 'tokenBased',
|
||||
features: {
|
||||
collaborators: 0
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
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(
|
||||
<ShareProjectModal
|
||||
{...modalProps}
|
||||
project={{
|
||||
...project,
|
||||
publicAccesLevel: 'tokenBased'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
// 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
|
||||
})
|
Loading…
Reference in a new issue