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

[web] remove deprecated project sharing modal

GitOrigin-RevId: 7b25918363c27154e0000e9497847217f2317fce
This commit is contained in:
Kristina
2025-02-04 09:04:33 +00:00
committed by Copybot
parent 229cae771e
commit 70a17768a3
39 changed files with 420 additions and 2304 deletions
@@ -72,7 +72,6 @@
"add_error_assist_annual_to_your_projects": "",
"add_error_assist_to_your_projects": "",
"add_files": "",
"add_more_collaborators": "",
"add_more_editors": "",
"add_more_managers": "",
"add_more_members": "",
@@ -123,7 +122,6 @@
"all_these_experiments_are_available_exclusively": "",
"allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "",
"already_subscribed_try_refreshing_the_page": "",
"also": "",
"an_email_has_already_been_sent_to": "",
"an_error_occured_while_restoring_project": "",
"an_error_occurred_when_verifying_the_coupon_code": "",
@@ -866,7 +864,6 @@
"link_accounts_and_add_email": "",
"link_institutional_email_get_started": "",
"link_sharing": "",
"link_sharing_is_off": "",
"link_sharing_is_off_short": "",
"link_sharing_is_on": "",
"link_to_github": "",
@@ -986,7 +983,6 @@
"need_more_than_x_licenses": "",
"need_to_add_new_primary_before_remove": "",
"need_to_leave": "",
"need_to_upgrade_for_more_collabs": "",
"new_compile_domain_notice": "",
"new_file": "",
"new_folder": "",
@@ -1284,7 +1280,6 @@
"remove": "",
"remove_access": "",
"remove_add_on": "",
"remove_collaborator": "",
"remove_from_group": "",
"remove_link": "",
"remove_manager": "",
@@ -1448,7 +1443,6 @@
"setup_another_account_under_a_personal_email_address": "",
"share": "",
"share_project": "",
"share_with_your_collabs": "",
"shared_with_you": "",
"sharelatex_beta_program": "",
"shortcut_to_open_advanced_reference_search": "",
@@ -3,9 +3,9 @@ import { useOnlineUsersContext } from '@/features/ide-react/context/online-users
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import * as eventTracking from '@/infrastructure/event-tracking'
import EditorNavigationToolbarRoot from '@/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root'
import ShareProjectModal from '@/features/share-project-modal/components/restricted-link-sharing/share-project-modal'
import EditorOverLimitModal from '@/features/share-project-modal/components/restricted-link-sharing/editor-over-limit-modal'
import ViewOnlyAccessModal from '@/features/share-project-modal/components/restricted-link-sharing/view-only-access-modal'
import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal'
import EditorOverLimitModal from '@/features/share-project-modal/components/editor-over-limit-modal'
import ViewOnlyAccessModal from '@/features/share-project-modal/components/view-only-access-modal'
function EditorNavigationToolbar() {
const [showShareModal, setShowShareModal] = useState(false)
@@ -1,104 +1,74 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useUserContext } from '../../../shared/context/user-context'
import Notification from '@/shared/components/notification'
import { upgradePlan } from '@/main/account-upgrade'
import StartFreeTrialButton from '../../../shared/components/start-free-trial-button'
import Icon from '../../../shared/components/icon'
import { useFeatureFlag } from '../../../shared/context/split-test-context'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import MaterialIcon from '@/shared/components/material-icon'
import { linkSharingEnforcementDate } from '../utils/link-sharing'
import { useProjectContext } from '@/shared/context/project-context'
import { useUserContext } from '@/shared/context/user-context'
import { sendMB } from '@/infrastructure/event-tracking'
import StartFreeTrialButton from '@/shared/components/start-free-trial-button'
import OLButton from '@/features/ui/components/ol/ol-button'
export default function AddCollaboratorsUpgrade() {
const { t } = useTranslation()
const { features } = useProjectContext()
const user = useUserContext()
const [startedFreeTrial, setStartedFreeTrial] = useState(false)
const hasNewPaywallCta = useFeatureFlag('paywall-cta')
return (
<div className="add-collaborators-upgrade">
<p className="text-center">
{t('need_to_upgrade_for_more_collabs')}. {t('also')}:
</p>
<ul className="list-unstyled">
<li>
<BootstrapVersionSwitcher
bs3={<Icon type="check" />}
bs5={<MaterialIcon type="check" className="align-text-bottom" />}
/>
&nbsp;
{t('unlimited_projects')}
</li>
<li>
<BootstrapVersionSwitcher
bs3={<Icon type="check" />}
bs5={<MaterialIcon type="check" className="align-text-bottom" />}
/>
&nbsp;
{t('collabs_per_proj', {
collabcount: 'Multiple',
})}
</li>
<li>
<BootstrapVersionSwitcher
bs3={<Icon type="check" />}
bs5={<MaterialIcon type="check" className="align-text-bottom" />}
/>
&nbsp;
{t('full_doc_history')}
</li>
<li>
<BootstrapVersionSwitcher
bs3={<Icon type="check" />}
bs5={<MaterialIcon type="check" className="align-text-bottom" />}
/>
&nbsp;
{t('sync_to_dropbox')}
</li>
<li>
<BootstrapVersionSwitcher
bs3={<Icon type="check" />}
bs5={<MaterialIcon type="check" className="align-text-bottom" />}
/>
&nbsp;
{t('sync_to_github')}
</li>
<li>
<BootstrapVersionSwitcher
bs3={<Icon type="check" />}
bs5={<MaterialIcon type="check" className="align-text-bottom" />}
/>
&nbsp;
{t('compile_larger_projects')}
</li>
</ul>
<p className="text-center row-spaced-thin">
{user.allowedFreeTrial ? (
<StartFreeTrialButton
buttonProps={{ variant: 'primary' }}
handleClick={() => setStartedFreeTrial(true)}
source="project-sharing"
>
{hasNewPaywallCta
? t('add_more_collaborators')
: t('start_free_trial')}
</StartFreeTrialButton>
) : (
<OLButton
variant="primary"
onClick={() => {
upgradePlan('project-sharing')
setStartedFreeTrial(true)
}}
>
{t('upgrade')}
</OLButton>
)}
</p>
{startedFreeTrial && (
<p className="small">{t('refresh_page_after_starting_free_trial')}</p>
)}
<Notification
isActionBelowContent
type="warning"
title={t('editor_limit_exceeded_in_this_project')}
content={
<p>
{t('your_plan_is_limited_to_n_editors', {
count: features.collaborators,
})}{' '}
{t('from_enforcement_date', {
enforcementDate: linkSharingEnforcementDate,
})}
</p>
}
action={
<div className="upgrade-actions">
{user.allowedFreeTrial ? (
<StartFreeTrialButton
buttonProps={{ variant: 'secondary', size: 'sm' }}
source="project-sharing"
variant="exceeds"
>
{t('upgrade')}
</StartFreeTrialButton>
) : (
<OLButton
variant="secondary"
size="sm"
onClick={() => {
upgradePlan('project-sharing')
}}
>
{t('upgrade')}
</OLButton>
)}
<OLButton
variant="link"
size="sm"
href="https://www.overleaf.com/blog/changes-to-project-sharing"
target="_blank"
rel="noreferrer"
onClick={() => {
sendMB('paywall-info-click', {
'paywall-type': 'project-sharing',
content: 'blog',
variant: 'exceeds',
})
}}
>
{t('read_more')}
</OLButton>
</div>
}
/>
</div>
)
}
@@ -1,20 +1,22 @@
import { useState, useMemo, useCallback } from 'react'
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useMultipleSelection } from 'downshift'
import { useShareProjectContext } from './share-project-modal'
import SelectCollaborators from './select-collaborators'
import { resendInvite, sendInvite } from '../utils/api'
import { useUserContacts } from '../hooks/use-user-contacts'
import useIsMounted from '../../../shared/hooks/use-is-mounted'
import { useProjectContext } from '../../../shared/context/project-context'
import { sendMB } from '../../../infrastructure/event-tracking'
import useIsMounted from '@/shared/hooks/use-is-mounted'
import { useProjectContext } from '@/shared/context/project-context'
import { sendMB } from '@/infrastructure/event-tracking'
import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
import PropTypes from 'prop-types'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
import OLButton from '@/features/ui/components/ol/ol-button'
import getMeta from '@/utils/meta'
export default function AddCollaborators() {
export default function AddCollaborators({ readOnly }) {
const [privileges, setPrivileges] = useState('readAndWrite')
const isMounted = useIsMounted()
@@ -49,6 +51,12 @@ export default function AddCollaborators() {
const { reset, selectedItems } = multipleSelectionProps
useEffect(() => {
if (readOnly && privileges === 'readAndWrite') {
setPrivileges('readOnly')
}
}, [privileges, readOnly])
const handleSubmit = useCallback(async () => {
if (!selectedItems.length) {
return
@@ -142,12 +150,12 @@ export default function AddCollaborators() {
])
return (
<OLForm>
<OLForm className="add-collabs">
<OLFormGroup>
<SelectCollaborators
loading={!nonMemberContacts}
options={nonMemberContacts || []}
placeholder="joe@example.com, sue@example.com, …"
placeholder="Email, comma separated"
multipleSelectionProps={multipleSelectionProps}
/>
</OLFormGroup>
@@ -162,8 +170,13 @@ export default function AddCollaborators() {
bsSize: 'sm',
}}
>
<option value="readAndWrite">{t('can_edit')}</option>
<option value="readOnly">{t('read_only')}</option>
<option disabled={readOnly} value="readAndWrite">
{t('can_edit')}
</option>
<option value="readOnly">{t('can_view')}</option>
{getMeta('ol-isReviewerRoleEnabled') && (
<option value="review">{t('can_review')}</option>
)}
</OLFormSelect>
<span>&nbsp;&nbsp;</span>
<ClickableElementEnhancer
@@ -171,10 +184,14 @@ export default function AddCollaborators() {
onClick={handleSubmit}
variant="primary"
>
{t('share')}
{t('invite')}
</ClickableElementEnhancer>
</div>
</OLFormGroup>
</OLForm>
)
}
AddCollaborators.propTypes = {
readOnly: PropTypes.bool,
}
@@ -1,203 +0,0 @@
import { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { useShareProjectContext } from './share-project-modal'
import TransferOwnershipModal from './transfer-ownership-modal'
import { removeMemberFromProject, updateMember } from '../utils/api'
import Icon from '../../../shared/components/icon'
import { useProjectContext } from '../../../shared/context/project-context'
import { sendMB } from '../../../infrastructure/event-tracking'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { bsVersion } from '@/features/utils/bootstrap-5'
export default function EditMember({ member }) {
const [privileges, setPrivileges] = useState(member.privileges)
const [confirmingOwnershipTransfer, setConfirmingOwnershipTransfer] =
useState(false)
// update the local state if the member's privileges change externally
useEffect(() => {
setPrivileges(member.privileges)
}, [member.privileges])
const { updateProject, monitorRequest } = useShareProjectContext()
const { _id: projectId, members } = useProjectContext()
function handleSubmit(event) {
event.preventDefault()
if (privileges === 'owner') {
setConfirmingOwnershipTransfer(true)
} else {
monitorRequest(() =>
updateMember(projectId, member, {
privilegeLevel: privileges,
})
).then(() => {
updateProject({
members: members.map(item =>
item._id === member._id ? { ...item, privileges } : item
),
})
})
}
}
if (confirmingOwnershipTransfer) {
return (
<TransferOwnershipModal
member={member}
cancel={() => setConfirmingOwnershipTransfer(false)}
/>
)
}
return (
<form
id="share-project-form"
className={bsVersion({ bs3: 'form-horizontal' })}
onSubmit={handleSubmit}
>
<OLFormGroup className="project-member row">
<OLCol xs={7} className={bsVersion({ bs3: 'pt-1', bs5: 'pt-2' })}>
{member.email}
</OLCol>
<OLCol xs={3}>
<SelectPrivilege
value={privileges}
handleChange={event => setPrivileges(event.target.value)}
/>
</OLCol>
<OLCol xs={2}>
{privileges === member.privileges ? (
<RemoveMemberAction member={member} />
) : (
<ChangePrivilegesActions
handleReset={() => setPrivileges(member.privileges)}
/>
)}
</OLCol>
</OLFormGroup>
</form>
)
}
EditMember.propTypes = {
member: PropTypes.shape({
_id: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
privileges: PropTypes.string.isRequired,
}),
}
function SelectPrivilege({ value, handleChange }) {
const { t } = useTranslation()
return (
<OLFormSelect
className="privileges"
value={value}
onChange={handleChange}
bs3Props={{
bsSize: 'sm',
}}
>
<option value="owner">{t('owner')}</option>
<option value="readAndWrite">{t('can_edit')}</option>
<option value="readOnly">{t('read_only')}</option>
</OLFormSelect>
)
}
SelectPrivilege.propTypes = {
value: PropTypes.string.isRequired,
handleChange: PropTypes.func.isRequired,
}
function RemoveMemberAction({ member }) {
const { t } = useTranslation()
const { updateProject, monitorRequest } = useShareProjectContext()
const { _id: projectId, members, invites } = useProjectContext()
function handleClick(event) {
event.preventDefault()
monitorRequest(() => removeMemberFromProject(projectId, member)).then(
() => {
const updatedMembers = members.filter(existing => existing !== member)
updateProject({
members: updatedMembers,
})
sendMB('collaborator-removed', {
project_id: projectId,
current_collaborators_amount: updatedMembers.length,
current_invites_amount: invites.length,
})
}
)
}
return (
<div className="text-center">
<OLTooltip
id="remove-collaborator"
description={t('remove_collaborator')}
overlayProps={{ placement: 'bottom' }}
>
<OLButton
variant="link"
onClick={handleClick}
className="remove-button"
aria-label={t('remove_collaborator')}
>
<BootstrapVersionSwitcher
bs3={<Icon type="times" />}
bs5={<MaterialIcon type="clear" />}
/>
</OLButton>
</OLTooltip>
</div>
)
}
RemoveMemberAction.propTypes = {
member: PropTypes.shape({
_id: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
privileges: PropTypes.string.isRequired,
}),
}
function ChangePrivilegesActions({ handleReset }) {
const { t } = useTranslation()
return (
<div className="text-center">
<OLButton type="submit" size="sm" variant="primary">
{t('change_or_cancel-change')}
</OLButton>
<div className="text-sm">
{t('change_or_cancel-or')}
&nbsp;
<OLButton
variant="link"
className="btn-inline-link"
onClick={handleReset}
>
{t('change_or_cancel-cancel')}
</OLButton>
</div>
</div>
)
}
ChangePrivilegesActions.propTypes = {
handleReset: PropTypes.func.isRequired,
}
@@ -3,14 +3,14 @@ import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { useShareProjectContext } from './share-project-modal'
import TransferOwnershipModal from './transfer-ownership-modal'
import { removeMemberFromProject, updateMember } from '../../utils/api'
import { removeMemberFromProject, updateMember } from '../utils/api'
import Icon from '@/shared/components/icon'
import { useProjectContext } from '@/shared/context/project-context'
import { sendMB } from '@/infrastructure/event-tracking'
import { Select } from '@/shared/components/select'
import type { ProjectContextMember } from '@/shared/context/types/project-context'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
import { linkSharingEnforcementDate } from '../../utils/link-sharing'
import { linkSharingEnforcementDate } from '../utils/link-sharing'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLCol from '@/features/ui/components/ol/ol-col'
@@ -128,7 +128,9 @@ export default function EditMember({
id="share-project-form"
onSubmit={e => {
e.preventDefault()
commitPrivilegeChange(privileges)
if (privilegeChangePending) {
commitPrivilegeChange(privileges)
}
}}
>
<OLFormGroup
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { linkSharingEnforcementDate } from '../../utils/link-sharing'
import { linkSharingEnforcementDate } from '../utils/link-sharing'
import { sendMB } from '@/infrastructure/event-tracking'
import OLButton from '@/features/ui/components/ol/ol-button'
import {
@@ -1,12 +1,12 @@
import { useCallback } from 'react'
import PropTypes from 'prop-types'
import { useShareProjectContext } from './share-project-modal'
import Icon from '../../../shared/components/icon'
import Icon from '@/shared/components/icon'
import { useTranslation } from 'react-i18next'
import MemberPrivileges from './member-privileges'
import { resendInvite, revokeInvite } from '../utils/api'
import { useProjectContext } from '../../../shared/context/project-context'
import { sendMB } from '../../../infrastructure/event-tracking'
import { useProjectContext } from '@/shared/context/project-context'
import { sendMB } from '@/infrastructure/event-tracking'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
@@ -20,9 +20,8 @@ export default function Invite({ invite, isProjectOwner }) {
const { t } = useTranslation()
return (
<OLRow className="project-invite">
<OLCol xs={7}>
<OLCol xs={8}>
<div>{invite.email}</div>
<div className="small">
{t('invite_not_accepted')}
.&nbsp;
@@ -30,12 +29,12 @@ export default function Invite({ invite, isProjectOwner }) {
</div>
</OLCol>
<OLCol xs={3} className="text-start">
<OLCol xs={3} className="text-end">
<MemberPrivileges privileges={invite.privileges} />
</OLCol>
{isProjectOwner && (
<OLCol xs={2} className="text-center">
<OLCol xs={1} className="text-center">
<RevokeInvite invite={invite} />
</OLCol>
)}
@@ -1,16 +1,16 @@
import { useCallback, useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
import Icon from '@/shared/components/icon'
import { useShareProjectContext } from './share-project-modal'
import { setProjectAccessLevel } from '../utils/api'
import { CopyToClipboard } from '@/shared/components/copy-to-clipboard'
import { useProjectContext } from '../../../shared/context/project-context'
import { useProjectContext } from '@/shared/context/project-context'
import * as eventTracking from '../../../infrastructure/event-tracking'
import { useUserContext } from '../../../shared/context/user-context'
import { useUserContext } from '@/shared/context/user-context'
import { sendMB } from '../../../infrastructure/event-tracking'
import { getJSON } from '../../../infrastructure/fetch-json'
import useAbortController from '../../../shared/hooks/use-abort-controller'
import useAbortController from '@/shared/hooks/use-abort-controller'
import { debugConsole } from '@/utils/debugging'
import getMeta from '@/utils/meta'
import OLRow from '@/features/ui/components/ol/ol-row'
@@ -22,6 +22,7 @@ import MaterialIcon from '@/shared/components/material-icon'
export default function LinkSharing() {
const [inflight, setInflight] = useState(false)
const [showLinks, setShowLinks] = useState(true)
const { monitorRequest } = useShareProjectContext()
@@ -57,6 +58,7 @@ export default function LinkSharing() {
setAccessLevel={setAccessLevel}
inflight={inflight}
projectId={projectId}
setShowLinks={setShowLinks}
/>
)
@@ -66,6 +68,8 @@ export default function LinkSharing() {
<TokenBasedSharing
setAccessLevel={setAccessLevel}
inflight={inflight}
setShowLinks={setShowLinks}
showLinks={showLinks}
/>
)
@@ -85,12 +89,12 @@ export default function LinkSharing() {
}
}
function PrivateSharing({ setAccessLevel, inflight, projectId }) {
function PrivateSharing({ setAccessLevel, inflight, projectId, setShowLinks }) {
const { t } = useTranslation()
return (
<OLRow className="public-access-level">
<OLCol xs={12} className="text-center">
{t('link_sharing_is_off')}
<strong>{t('link_sharing_is_off_short')}</strong>
<span>&nbsp;&nbsp;</span>
<OLButton
variant="link"
@@ -98,6 +102,7 @@ function PrivateSharing({ setAccessLevel, inflight, projectId }) {
onClick={() => {
setAccessLevel('tokenBased')
eventTracking.sendMB('link-sharing-click', { projectId })
setShowLinks(true)
}}
disabled={inflight}
>
@@ -114,9 +119,15 @@ PrivateSharing.propTypes = {
setAccessLevel: PropTypes.func.isRequired,
inflight: PropTypes.bool,
projectId: PropTypes.string,
setShowLinks: PropTypes.func.isRequired,
}
function TokenBasedSharing({ setAccessLevel, inflight }) {
function TokenBasedSharing({
setAccessLevel,
inflight,
setShowLinks,
showLinks,
}) {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
@@ -145,27 +156,43 @@ function TokenBasedSharing({ setAccessLevel, inflight }) {
</OLButton>
<span>&nbsp;&nbsp;</span>
<LinkSharingInfo />
</OLCol>
<OLCol xs={12} className="access-token-display-area">
<div className="access-token-wrapper">
<strong>{t('anyone_with_link_can_edit')}</strong>
<AccessToken
token={tokens?.readAndWrite}
tokenHashPrefix={tokens?.readAndWriteHashPrefix}
path="/"
tooltipId="tooltip-copy-link-rw"
<OLButton
variant="link"
className="btn-chevron align-middle"
onClick={() => setShowLinks(!showLinks)}
>
<BootstrapVersionSwitcher
bs3={<Icon type={showLinks ? 'chevron-up' : 'chevron-down'} fw />}
bs5={
<MaterialIcon
type={showLinks ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
/>
}
/>
</div>
<div className="access-token-wrapper">
<strong>{t('anyone_with_link_can_view')}</strong>
<AccessToken
token={tokens?.readOnly}
tokenHashPrefix={tokens?.readOnlyHashPrefix}
path="/read/"
tooltipId="tooltip-copy-link-ro"
/>
</div>
</OLButton>
</OLCol>
{showLinks && (
<OLCol xs={12} className="access-token-display-area">
<div className="access-token-wrapper">
<strong>{t('anyone_with_link_can_edit')}</strong>
<AccessToken
token={tokens?.readAndWrite}
tokenHashPrefix={tokens?.readAndWriteHashPrefix}
path="/"
tooltipId="tooltip-copy-link-rw"
/>
</div>
<div className="access-token-wrapper">
<strong>{t('anyone_with_link_can_view')}</strong>
<AccessToken
token={tokens?.readOnly}
tokenHashPrefix={tokens?.readOnlyHashPrefix}
path="/read/"
tooltipId="tooltip-copy-link-ro"
/>
</div>
</OLCol>
)}
</OLRow>
)
}
@@ -173,6 +200,8 @@ function TokenBasedSharing({ setAccessLevel, inflight }) {
TokenBasedSharing.propTypes = {
setAccessLevel: PropTypes.func.isRequired,
inflight: PropTypes.bool,
setShowLinks: PropTypes.func.isRequired,
showLinks: PropTypes.bool,
}
function LegacySharing({ accessLevel, setAccessLevel, inflight }) {
@@ -1,7 +1,10 @@
import { useProjectContext } from '../../../shared/context/project-context'
import { useProjectContext } from '@/shared/context/project-context'
import { useTranslation } from 'react-i18next'
import Icon from '@/shared/components/icon'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import MaterialIcon from '@/shared/components/material-icon'
export default function OwnerInfo() {
const { t } = useTranslation()
@@ -9,8 +12,16 @@ export default function OwnerInfo() {
return (
<OLRow className="project-member">
<OLCol xs={7}>{owner?.email}</OLCol>
<OLCol xs={3} className="text-start">
<OLCol xs={8}>
<div className="project-member-email-icon">
<BootstrapVersionSwitcher
bs3={<Icon type="user" fw />}
bs5={<MaterialIcon type="person" />}
/>
<div className="email-warning">{owner?.email}</div>
</div>
</OLCol>
<OLCol xs={4} className="text-end">
{t('owner')}
</OLCol>
</OLRow>
@@ -1,74 +0,0 @@
import { useTranslation } from 'react-i18next'
import Notification from '@/shared/components/notification'
import { upgradePlan } from '@/main/account-upgrade'
import { linkSharingEnforcementDate } from '../../utils/link-sharing'
import { useProjectContext } from '@/shared/context/project-context'
import { useUserContext } from '@/shared/context/user-context'
import { sendMB } from '@/infrastructure/event-tracking'
import StartFreeTrialButton from '@/shared/components/start-free-trial-button'
import OLButton from '@/features/ui/components/ol/ol-button'
export default function AddCollaboratorsUpgrade() {
const { t } = useTranslation()
const { features } = useProjectContext()
const user = useUserContext()
return (
<div className="add-collaborators-upgrade">
<Notification
isActionBelowContent
type="warning"
title={t('editor_limit_exceeded_in_this_project')}
content={
<p>
{t('your_plan_is_limited_to_n_editors', {
count: features.collaborators,
})}{' '}
{t('from_enforcement_date', {
enforcementDate: linkSharingEnforcementDate,
})}
</p>
}
action={
<div className="upgrade-actions">
{user.allowedFreeTrial ? (
<StartFreeTrialButton
buttonProps={{ variant: 'secondary', size: 'sm' }}
source="project-sharing"
variant="exceeds"
>
{t('upgrade')}
</StartFreeTrialButton>
) : (
<OLButton
variant="secondary"
size="sm"
onClick={() => {
upgradePlan('project-sharing')
}}
>
{t('upgrade')}
</OLButton>
)}
<OLButton
variant="link"
size="sm"
href="https://www.overleaf.com/blog/changes-to-project-sharing"
target="_blank"
rel="noreferrer"
onClick={() => {
sendMB('paywall-info-click', {
'paywall-type': 'project-sharing',
content: 'blog',
variant: 'exceeds',
})
}}
>
{t('read_more')}
</OLButton>
</div>
}
/>
</div>
)
}
@@ -1,197 +0,0 @@
import { useEffect, useState, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useMultipleSelection } from 'downshift'
import { useShareProjectContext } from './share-project-modal'
import SelectCollaborators from './select-collaborators'
import { resendInvite, sendInvite } from '../../utils/api'
import { useUserContacts } from '../../hooks/use-user-contacts'
import useIsMounted from '@/shared/hooks/use-is-mounted'
import { useProjectContext } from '@/shared/context/project-context'
import { sendMB } from '@/infrastructure/event-tracking'
import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
import PropTypes from 'prop-types'
import OLForm from '@/features/ui/components/ol/ol-form'
import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
import OLButton from '@/features/ui/components/ol/ol-button'
import getMeta from '@/utils/meta'
export default function AddCollaborators({ readOnly }) {
const [privileges, setPrivileges] = useState('readAndWrite')
const isMounted = useIsMounted()
const { data: contacts } = useUserContacts()
const { t } = useTranslation()
const { updateProject, setInFlight, setError } = useShareProjectContext()
const { _id: projectId, members, invites } = useProjectContext()
const currentMemberEmails = useMemo(
() => (members || []).map(member => member.email).sort(),
[members]
)
const nonMemberContacts = useMemo(() => {
if (!contacts) {
return null
}
return contacts.filter(
contact => !currentMemberEmails.includes(contact.email)
)
}, [contacts, currentMemberEmails])
const multipleSelectionProps = useMultipleSelection({
initialActiveIndex: 0,
initialSelectedItems: [],
})
const { reset, selectedItems } = multipleSelectionProps
useEffect(() => {
if (readOnly && privileges === 'readAndWrite') {
setPrivileges('readOnly')
}
}, [privileges, readOnly])
const handleSubmit = useCallback(async () => {
if (!selectedItems.length) {
return
}
// reset the selected items
reset()
setError(undefined)
setInFlight(true)
for (const contact of selectedItems) {
// unmounting means can't add any more collaborators
if (!isMounted.current) {
break
}
const email = contact.type === 'user' ? contact.email : contact.display
const normalisedEmail = email.toLowerCase()
if (currentMemberEmails.includes(normalisedEmail)) {
continue
}
let data
try {
const invite = (invites || []).find(
invite => invite.email === normalisedEmail
)
if (invite) {
data = await resendInvite(projectId, invite)
} else {
data = await sendInvite(projectId, email, privileges)
}
sendMB('collaborator-invited', {
project_id: projectId,
// invitation is only populated on successful invite, meaning that for paywall and other cases this will be null
successful_invite: !!data.invite,
users_updated: !!(data.users || data.user),
current_collaborators_amount: members.length,
current_invites_amount: invites.length,
})
} catch (error) {
setInFlight(false)
setError(
error.data?.errorReason ||
(error.response?.status === 429
? 'too_many_requests'
: 'generic_something_went_wrong')
)
break
}
if (data.error) {
setError(data.error)
setInFlight(false)
} else if (data.invite) {
updateProject({
invites: invites.concat(data.invite),
})
} else if (data.users) {
updateProject({
members: members.concat(data.users),
})
} else if (data.user) {
updateProject({
members: members.concat(data.user),
})
}
// wait for a short time, so canAddCollaborators has time to update with new collaborator information
await new Promise(resolve => setTimeout(resolve, 100))
}
setInFlight(false)
}, [
currentMemberEmails,
invites,
isMounted,
members,
privileges,
projectId,
reset,
selectedItems,
setError,
setInFlight,
updateProject,
])
return (
<OLForm className="add-collabs">
<OLFormGroup>
<SelectCollaborators
loading={!nonMemberContacts}
options={nonMemberContacts || []}
placeholder="Email, comma separated"
multipleSelectionProps={multipleSelectionProps}
/>
</OLFormGroup>
<OLFormGroup>
<div className="pull-right">
<OLFormSelect
className="privileges"
value={privileges}
onChange={event => setPrivileges(event.target.value)}
bs3Props={{
bsSize: 'sm',
}}
>
<option disabled={readOnly} value="readAndWrite">
{t('can_edit')}
</option>
<option value="readOnly">{t('can_view')}</option>
{getMeta('ol-isReviewerRoleEnabled') && (
<option value="review">{t('can_review')}</option>
)}
</OLFormSelect>
<span>&nbsp;&nbsp;</span>
<ClickableElementEnhancer
as={OLButton}
onClick={handleSubmit}
variant="primary"
>
{t('invite')}
</ClickableElementEnhancer>
</div>
</OLFormGroup>
</OLForm>
)
}
AddCollaborators.propTypes = {
readOnly: PropTypes.bool,
}
@@ -1,142 +0,0 @@
import { useCallback } from 'react'
import PropTypes from 'prop-types'
import { useShareProjectContext } from './share-project-modal'
import Icon from '@/shared/components/icon'
import { useTranslation } from 'react-i18next'
import MemberPrivileges from './member-privileges'
import { resendInvite, revokeInvite } from '../../utils/api'
import { useProjectContext } from '@/shared/context/project-context'
import { sendMB } from '@/infrastructure/event-tracking'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import OLButton from '@/features/ui/components/ol/ol-button'
import MaterialIcon from '@/shared/components/material-icon'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { bsVersion } from '@/features/utils/bootstrap-5'
import classnames from 'classnames'
export default function Invite({ invite, isProjectOwner }) {
const { t } = useTranslation()
return (
<OLRow className="project-invite">
<OLCol xs={8}>
<div>{invite.email}</div>
<div className="small">
{t('invite_not_accepted')}
.&nbsp;
{isProjectOwner && <ResendInvite invite={invite} />}
</div>
</OLCol>
<OLCol xs={3} className="text-end">
<MemberPrivileges privileges={invite.privileges} />
</OLCol>
{isProjectOwner && (
<OLCol xs={1} className="text-center">
<RevokeInvite invite={invite} />
</OLCol>
)}
</OLRow>
)
}
Invite.propTypes = {
invite: PropTypes.object.isRequired,
isProjectOwner: PropTypes.bool.isRequired,
}
function ResendInvite({ invite }) {
const { t } = useTranslation()
const { monitorRequest, setError, inFlight } = useShareProjectContext()
const { _id: projectId } = useProjectContext()
// const buttonRef = useRef(null)
//
const handleClick = useCallback(
() =>
monitorRequest(() => resendInvite(projectId, invite))
.catch(error => {
if (error?.response?.status === 404) {
setError('invite_expired')
}
if (error?.response?.status === 429) {
setError('invite_resend_limit_hit')
}
})
.finally(() => {
// NOTE: disabled as react-bootstrap v0.33.1 isn't forwarding the ref to the `button`
// if (buttonRef.current) {
// buttonRef.current.blur()
// }
document.activeElement.blur()
}),
[invite, monitorRequest, projectId, setError]
)
return (
<OLButton
variant="link"
className="btn-inline-link"
onClick={handleClick}
disabled={inFlight}
// ref={buttonRef}
>
{t('resend')}
</OLButton>
)
}
ResendInvite.propTypes = {
invite: PropTypes.object.isRequired,
}
function RevokeInvite({ invite }) {
const { t } = useTranslation()
const { updateProject, monitorRequest } = useShareProjectContext()
const { _id: projectId, invites, members } = useProjectContext()
function handleClick(event) {
event.preventDefault()
monitorRequest(() => revokeInvite(projectId, invite)).then(() => {
const updatedInvites = invites.filter(existing => existing !== invite)
updateProject({
invites: updatedInvites,
})
sendMB('collaborator-invite-revoked', {
project_id: projectId,
current_invites_amount: updatedInvites.length,
current_collaborators_amount: members.length,
})
})
}
return (
<OLTooltip
id="revoke-invite"
description={t('revoke_invite')}
overlayProps={{ placement: 'bottom' }}
>
<OLButton
variant="link"
onClick={handleClick}
aria-label={t('revoke')}
className={classnames(
'btn-inline-link',
bsVersion({ bs5: 'text-decoration-none' })
)}
>
<BootstrapVersionSwitcher
bs3={<Icon type="times" />}
bs5={<MaterialIcon type="clear" />}
/>
</OLButton>
</OLTooltip>
)
}
RevokeInvite.propTypes = {
invite: PropTypes.object.isRequired,
}
@@ -1,325 +0,0 @@
import { useCallback, useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import Icon from '@/shared/components/icon'
import { useShareProjectContext } from './share-project-modal'
import { setProjectAccessLevel } from '../../utils/api'
import { CopyToClipboard } from '@/shared/components/copy-to-clipboard'
import { useProjectContext } from '@/shared/context/project-context'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import { useUserContext } from '@/shared/context/user-context'
import { sendMB } from '../../../../infrastructure/event-tracking'
import { getJSON } from '../../../../infrastructure/fetch-json'
import useAbortController from '@/shared/hooks/use-abort-controller'
import { debugConsole } from '@/utils/debugging'
import getMeta from '@/utils/meta'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import OLButton from '@/features/ui/components/ol/ol-button'
import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import MaterialIcon from '@/shared/components/material-icon'
export default function LinkSharing() {
const [inflight, setInflight] = useState(false)
const [showLinks, setShowLinks] = useState(true)
const { monitorRequest } = useShareProjectContext()
const { _id: projectId, publicAccessLevel } = useProjectContext()
// set the access level of a project
const setAccessLevel = useCallback(
newPublicAccessLevel => {
setInflight(true)
sendMB('link-sharing-click-off', {
project_id: projectId,
})
monitorRequest(() =>
setProjectAccessLevel(projectId, newPublicAccessLevel)
)
.then(() => {
// NOTE: not calling `updateProject` here as it receives data via
// project:publicAccessLevel:changed over the websocket connection
// TODO: eventTracking.sendMB('project-make-token-based') when newPublicAccessLevel is 'tokenBased'
})
.finally(() => {
setInflight(false)
})
},
[monitorRequest, projectId]
)
switch (publicAccessLevel) {
// Private (with token-access available)
case 'private':
return (
<PrivateSharing
setAccessLevel={setAccessLevel}
inflight={inflight}
projectId={projectId}
setShowLinks={setShowLinks}
/>
)
// Token-based access
case 'tokenBased':
return (
<TokenBasedSharing
setAccessLevel={setAccessLevel}
inflight={inflight}
setShowLinks={setShowLinks}
showLinks={showLinks}
/>
)
// Legacy public-access
case 'readAndWrite':
case 'readOnly':
return (
<LegacySharing
setAccessLevel={setAccessLevel}
accessLevel={publicAccessLevel}
inflight={inflight}
/>
)
default:
return null
}
}
function PrivateSharing({ setAccessLevel, inflight, projectId, setShowLinks }) {
const { t } = useTranslation()
return (
<OLRow className="public-access-level">
<OLCol xs={12} className="text-center">
<strong>{t('link_sharing_is_off_short')}</strong>
<span>&nbsp;&nbsp;</span>
<OLButton
variant="link"
className="btn-inline-link"
onClick={() => {
setAccessLevel('tokenBased')
eventTracking.sendMB('link-sharing-click', { projectId })
setShowLinks(true)
}}
disabled={inflight}
>
{t('turn_on_link_sharing')}
</OLButton>
<span>&nbsp;&nbsp;</span>
<LinkSharingInfo />
</OLCol>
</OLRow>
)
}
PrivateSharing.propTypes = {
setAccessLevel: PropTypes.func.isRequired,
inflight: PropTypes.bool,
projectId: PropTypes.string,
setShowLinks: PropTypes.func.isRequired,
}
function TokenBasedSharing({
setAccessLevel,
inflight,
setShowLinks,
showLinks,
}) {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const [tokens, setTokens] = useState(null)
const { signal } = useAbortController()
useEffect(() => {
getJSON(`/project/${projectId}/tokens`, { signal })
.then(data => setTokens(data))
.catch(debugConsole.error)
}, [projectId, signal])
return (
<OLRow className="public-access-level">
<OLCol xs={12} className="text-center">
<strong>{t('link_sharing_is_on')}</strong>
<span>&nbsp;&nbsp;</span>
<OLButton
variant="link"
className="btn-inline-link"
onClick={() => setAccessLevel('private')}
disabled={inflight}
>
{t('turn_off_link_sharing')}
</OLButton>
<span>&nbsp;&nbsp;</span>
<LinkSharingInfo />
<OLButton
variant="link"
className="btn-chevron align-middle"
onClick={() => setShowLinks(!showLinks)}
>
<BootstrapVersionSwitcher
bs3={<Icon type={showLinks ? 'chevron-up' : 'chevron-down'} fw />}
bs5={
<MaterialIcon
type={showLinks ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
/>
}
/>
</OLButton>
</OLCol>
{showLinks && (
<OLCol xs={12} className="access-token-display-area">
<div className="access-token-wrapper">
<strong>{t('anyone_with_link_can_edit')}</strong>
<AccessToken
token={tokens?.readAndWrite}
tokenHashPrefix={tokens?.readAndWriteHashPrefix}
path="/"
tooltipId="tooltip-copy-link-rw"
/>
</div>
<div className="access-token-wrapper">
<strong>{t('anyone_with_link_can_view')}</strong>
<AccessToken
token={tokens?.readOnly}
tokenHashPrefix={tokens?.readOnlyHashPrefix}
path="/read/"
tooltipId="tooltip-copy-link-ro"
/>
</div>
</OLCol>
)}
</OLRow>
)
}
TokenBasedSharing.propTypes = {
setAccessLevel: PropTypes.func.isRequired,
inflight: PropTypes.bool,
setShowLinks: PropTypes.func.isRequired,
showLinks: PropTypes.bool,
}
function LegacySharing({ accessLevel, setAccessLevel, inflight }) {
const { t } = useTranslation()
return (
<OLRow className="public-access-level">
<OLCol xs={12} className="text-center">
<strong>
{accessLevel === 'readAndWrite' && t('this_project_is_public')}
{accessLevel === 'readOnly' && t('this_project_is_public_read_only')}
</strong>
<span>&nbsp;&nbsp;</span>
<OLButton
variant="link"
className="btn-inline-link"
onClick={() => setAccessLevel('private')}
disabled={inflight}
>
{t('make_private')}
</OLButton>
<span>&nbsp;&nbsp;</span>
<LinkSharingInfo />
</OLCol>
</OLRow>
)
}
LegacySharing.propTypes = {
accessLevel: PropTypes.string.isRequired,
setAccessLevel: PropTypes.func.isRequired,
inflight: PropTypes.bool,
}
export function ReadOnlyTokenLink() {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const [tokens, setTokens] = useState(null)
const { signal } = useAbortController()
useEffect(() => {
getJSON(`/project/${projectId}/tokens`, { signal })
.then(data => setTokens(data))
.catch(debugConsole.error)
}, [projectId, signal])
return (
<OLRow className="public-access-level">
<OLCol className="access-token-display-area">
<div className="access-token-wrapper">
<strong>{t('anyone_with_link_can_view')}</strong>
<AccessToken
token={tokens?.readOnly}
tokenHashPrefix={tokens?.readOnlyHashPrefix}
path="/read/"
tooltipId="tooltip-copy-link-ro"
/>
</div>
</OLCol>
</OLRow>
)
}
function AccessToken({ token, tokenHashPrefix, path, tooltipId }) {
const { t } = useTranslation()
const { isAdmin } = useUserContext()
if (!token) {
return (
<pre className="access-token">
<span>{t('loading')}</span>
</pre>
)
}
let origin = window.location.origin
if (isAdmin) {
origin = getMeta('ol-ExposedSettings').siteUrl
}
const link = `${origin}${path}${token}${
tokenHashPrefix ? `#${tokenHashPrefix}` : ''
}`
return (
<div className="access-token">
<code>{link}</code>
<CopyToClipboard content={link} tooltipId={tooltipId} />
</div>
)
}
AccessToken.propTypes = {
token: PropTypes.string,
tokenHashPrefix: PropTypes.string,
tooltipId: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
}
function LinkSharingInfo() {
const { t } = useTranslation()
return (
<OLTooltip
id="link-sharing-info"
description={t('learn_more_about_link_sharing')}
>
<a
href="/learn/how-to/What_is_Link_Sharing%3F"
target="_blank"
rel="noopener"
>
<BootstrapVersionSwitcher
bs3={<Icon type="question-circle" />}
bs5={<MaterialIcon type="help" className="align-middle" />}
/>
</a>
</OLTooltip>
)
}
@@ -1,23 +0,0 @@
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
export default function MemberPrivileges({ privileges }) {
const { t } = useTranslation()
switch (privileges) {
case 'readAndWrite':
return t('can_edit')
case 'readOnly':
return t('read_only')
case 'review':
return t('can_review')
default:
return null
}
}
MemberPrivileges.propTypes = {
privileges: PropTypes.string.isRequired,
}
@@ -1,29 +0,0 @@
import { useProjectContext } from '@/shared/context/project-context'
import { useTranslation } from 'react-i18next'
import Icon from '@/shared/components/icon'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import MaterialIcon from '@/shared/components/material-icon'
export default function OwnerInfo() {
const { t } = useTranslation()
const { owner } = useProjectContext()
return (
<OLRow className="project-member">
<OLCol xs={8}>
<div className="project-member-email-icon">
<BootstrapVersionSwitcher
bs3={<Icon type="user" fw />}
bs5={<MaterialIcon type="person" />}
/>
<div className="email-warning">{owner?.email}</div>
</div>
</OLCol>
<OLCol xs={4} className="text-end">
{t('owner')}
</OLCol>
</OLRow>
)
}
@@ -1,419 +0,0 @@
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { matchSorter } from 'match-sorter'
import { useCombobox } from 'downshift'
import classnames from 'classnames'
import Icon from '@/shared/components/icon'
import MaterialIcon from '@/shared/components/material-icon'
import Tag from '@/features/ui/components/bootstrap-5/tag'
import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { bsVersion } from '@/features/utils/bootstrap-5'
import { Spinner } from 'react-bootstrap-5'
// Unicode characters in these Unicode groups:
// "General Punctuation Spaces"
// "General Punctuation Format character" (including zero-width spaces)
const matchAllSpaces =
/[\u061C\u2000-\u200F\u202A-\u202E\u2060\u2066-\u2069\u2028\u2029\u202F]/g
export default function SelectCollaborators({
loading,
options,
placeholder,
multipleSelectionProps,
}) {
const { t } = useTranslation()
const {
getSelectedItemProps,
getDropdownProps,
addSelectedItem,
removeSelectedItem,
selectedItems,
} = multipleSelectionProps
const [inputValue, setInputValue] = useState('')
const selectedEmails = useMemo(
() => selectedItems.map(item => item.email),
[selectedItems]
)
const unselectedOptions = useMemo(
() => options.filter(option => !selectedEmails.includes(option.email)),
[options, selectedEmails]
)
const filteredOptions = useMemo(() => {
if (inputValue === '') {
return unselectedOptions
}
return matchSorter(unselectedOptions, inputValue, {
keys: ['name', 'email'],
threshold: matchSorter.rankings.CONTAINS,
baseSort: (a, b) => {
// Prefer server-side sorting for ties in the match ranking.
return a.index - b.index > 0 ? 1 : -1
},
})
}, [unselectedOptions, inputValue])
const inputRef = useRef(null)
const focusInput = useCallback(() => {
if (inputRef.current) {
window.setTimeout(() => {
inputRef.current.focus()
}, 10)
}
}, [inputRef])
const isValidInput = useMemo(() => {
if (inputValue.includes('@')) {
for (const selectedItem of selectedItems) {
if (selectedItem.email === inputValue) {
return false
}
}
}
return true
}, [inputValue, selectedItems])
function stateReducer(state, actionAndChanges) {
const { type, changes } = actionAndChanges
// force selected item to be null so that adding, removing, then re-adding the same collaborator is recognised as a selection change
if (type === useCombobox.stateChangeTypes.InputChange) {
return { ...changes, selectedItem: null }
}
return changes
}
const {
isOpen,
getLabelProps,
getMenuProps,
getInputProps,
getComboboxProps,
highlightedIndex,
getItemProps,
reset,
} = useCombobox({
inputValue,
defaultHighlightedIndex: 0,
items: filteredOptions,
itemToString: item => item && item.name,
stateReducer,
onStateChange: ({ inputValue, type, selectedItem }) => {
switch (type) {
// add a selected item on Enter (keypress), click or blur
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
case useCombobox.stateChangeTypes.InputBlur:
if (selectedItem) {
setInputValue('')
addSelectedItem(selectedItem)
}
break
}
},
})
const addNewItem = useCallback(
(_email, focus = true) => {
const email = _email.replace(matchAllSpaces, '')
if (
isValidInput &&
email.includes('@') &&
!selectedEmails.includes(email)
) {
addSelectedItem({
email,
display: email,
type: 'user',
})
setInputValue('')
reset()
if (focus) {
focusInput()
}
return true
}
},
[addSelectedItem, selectedEmails, isValidInput, focusInput, reset]
)
// close and reset the menu when there are no matching items
useEffect(() => {
if (isOpen && filteredOptions.length === 0) {
reset()
}
}, [reset, isOpen, filteredOptions.length])
return (
<div className="tags-input tags-new">
{/* eslint-disable-next-line jsx-a11y/label-has-for */}
<label className="small" {...getLabelProps()}>
<strong>
{t('add_people')}
&nbsp;
</strong>
{loading && (
<BootstrapVersionSwitcher
bs3={<Icon type="refresh" spin />}
bs5={
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
}
/>
)}
</label>
<div className="host">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<div
{...getComboboxProps()}
className="tags form-control"
onClick={focusInput}
>
{selectedItems.map((selectedItem, index) => (
<SelectedItem
key={`selected-item-${index}`}
removeSelectedItem={removeSelectedItem}
selectedItem={selectedItem}
focusInput={focusInput}
index={index}
getSelectedItemProps={getSelectedItemProps}
/>
))}
<input
{...getInputProps(
getDropdownProps({
className: classnames('input', {
'invalid-tag': !isValidInput,
}),
type: 'email',
placeholder,
size: inputValue.length
? inputValue.length + 5
: placeholder.length,
ref: inputRef,
// preventKeyAction: showDropdown,
onBlur: () => {
addNewItem(inputValue, false)
},
onChange: e => {
setInputValue(e.target.value)
},
onClick: () => focusInput,
onKeyDown: event => {
switch (event.key) {
case 'Enter':
// Enter: always prevent form submission
event.preventDefault()
event.stopPropagation()
break
case 'Tab':
// Tab: if the dropdown isn't open, try to create a new item using inputValue and prevent blur if successful
if (!isOpen && addNewItem(inputValue)) {
event.preventDefault()
event.stopPropagation()
}
break
case ',':
// comma: try to create a new item using inputValue
event.preventDefault()
addNewItem(inputValue)
break
}
},
onPaste: event => {
const data =
// modern browsers
event.clipboardData?.getData('text/plain') ??
// IE11
window.clipboardData?.getData('text')
if (data) {
const emails = data
.split(/[\r\n,; ]+/)
.filter(item => item.includes('@'))
if (emails.length) {
// pasted comma-separated email addresses
event.preventDefault()
for (const email of emails) {
addNewItem(email)
}
}
}
},
})
)}
/>
</div>
<div
className={bsVersion({ bs3: classnames({ autocomplete: isOpen }) })}
>
<ul
{...getMenuProps()}
className={classnames(
bsVersion({
bs3: 'suggestion-list',
bs5: classnames('dropdown-menu select-dropdown-menu', {
show: isOpen,
}),
})
)}
>
{isOpen &&
filteredOptions.map((item, index) => (
<Option
key={item.email}
index={index}
item={item}
selected={index === highlightedIndex}
getItemProps={getItemProps}
/>
))}
</ul>
</div>
</div>
</div>
)
}
SelectCollaborators.propTypes = {
loading: PropTypes.bool.isRequired,
options: PropTypes.array.isRequired,
placeholder: PropTypes.string,
multipleSelectionProps: PropTypes.shape({
getSelectedItemProps: PropTypes.func.isRequired,
getDropdownProps: PropTypes.func.isRequired,
addSelectedItem: PropTypes.func.isRequired,
removeSelectedItem: PropTypes.func.isRequired,
selectedItems: PropTypes.array.isRequired,
}).isRequired,
}
function Option({ selected, item, getItemProps, index }) {
return (
<li
className={bsVersion({
bs3: classnames('suggestion-item', { selected }),
})}
{...getItemProps({ item, index })}
>
<BootstrapVersionSwitcher
bs3={
<>
<Icon type="user" fw />
&nbsp;
{item.display}
</>
}
bs5={
<DropdownItem
as="span"
role={undefined}
leadingIcon="person"
className={classnames({
active: selected,
})}
>
{item.display}
</DropdownItem>
}
/>
</li>
)
}
Option.propTypes = {
selected: PropTypes.bool.isRequired,
item: PropTypes.shape({
display: PropTypes.string.isRequired,
}),
index: PropTypes.number.isRequired,
getItemProps: PropTypes.func.isRequired,
}
function SelectedItem({
removeSelectedItem,
selectedItem,
focusInput,
getSelectedItemProps,
index,
}) {
const { t } = useTranslation()
const handleClick = useCallback(
event => {
event.preventDefault()
event.stopPropagation()
removeSelectedItem(selectedItem)
focusInput()
},
[focusInput, removeSelectedItem, selectedItem]
)
return (
<BootstrapVersionSwitcher
bs3={
<span
className="tag-item"
{...getSelectedItemProps({ selectedItem, index })}
>
<Icon type="user" fw />
<span>{selectedItem.display}</span>
<button
type="button"
className="remove-button btn-inline-link"
aria-label={t('remove')}
onClick={handleClick}
>
<Icon type="close" fw />
</button>
</span>
}
bs5={
<Tag
prepend={
<BootstrapVersionSwitcher
bs3={<Icon type="user" fw />}
bs5={<MaterialIcon type="person" />}
/>
}
closeBtnProps={{
onClick: handleClick,
}}
{...getSelectedItemProps({ selectedItem, index })}
>
{selectedItem.display}
</Tag>
}
/>
)
}
SelectedItem.propTypes = {
focusInput: PropTypes.func.isRequired,
removeSelectedItem: PropTypes.func.isRequired,
selectedItem: PropTypes.shape({
display: PropTypes.string.isRequired,
}),
getSelectedItemProps: PropTypes.func.isRequired,
index: PropTypes.number.isRequired,
}
@@ -1,39 +0,0 @@
import AddCollaborators from './add-collaborators'
import AddCollaboratorsUpgrade from './add-collaborators-upgrade'
import CollaboratorsLimitUpgrade from './collaborators-limit-upgrade'
import AccessLevelsChanged from './access-levels-changed'
import PropTypes from 'prop-types'
import OLRow from '@/features/ui/components/ol/ol-row'
export default function SendInvites({
canAddCollaborators,
hasExceededCollaboratorLimit,
haveAnyEditorsBeenDowngraded,
somePendingEditorsResolved,
}) {
return (
<OLRow className="invite-controls">
{hasExceededCollaboratorLimit && !haveAnyEditorsBeenDowngraded && (
<AddCollaboratorsUpgrade />
)}
{haveAnyEditorsBeenDowngraded && (
<AccessLevelsChanged
somePendingEditorsResolved={somePendingEditorsResolved}
/>
)}
{!canAddCollaborators &&
!hasExceededCollaboratorLimit &&
!haveAnyEditorsBeenDowngraded && <CollaboratorsLimitUpgrade />}
<AddCollaborators readOnly={!canAddCollaborators} />
</OLRow>
)
}
SendInvites.propTypes = {
canAddCollaborators: PropTypes.bool,
hasExceededCollaboratorLimit: PropTypes.bool,
haveAnyEditorsBeenDowngraded: PropTypes.bool,
somePendingEditorsResolved: PropTypes.bool,
}
@@ -1,128 +0,0 @@
import EditMember from './edit-member'
import LinkSharing from './link-sharing'
import Invite from './invite'
import SendInvites from './send-invites'
import ViewMember from './view-member'
import OwnerInfo from './owner-info'
import SendInvitesNotice from './send-invites-notice'
import { useEditorContext } from '@/shared/context/editor-context'
import { useProjectContext } from '@/shared/context/project-context'
import { useMemo } from 'react'
import RecaptchaConditions from '@/shared/components/recaptcha-conditions'
import getMeta from '@/utils/meta'
export default function ShareModalBody() {
const { members, invites, features } = useProjectContext()
const { isProjectOwner } = useEditorContext()
// whether the project has not reached the collaborator limit
const canAddCollaborators = useMemo(() => {
if (!isProjectOwner || !features) {
return false
}
if (features.collaborators === -1) {
// infinite collaborators
return true
}
const editorInvites = invites.filter(
invite => invite.privileges === 'readAndWrite'
).length
return (
members.filter(member => member.privileges === 'readAndWrite').length +
editorInvites <
(features.collaborators ?? 1)
)
}, [members, invites, features, isProjectOwner])
// determine if some but not all pending editors' permissions have been resolved,
// for moving between warning and info notification states etc.
const somePendingEditorsResolved = useMemo(() => {
return (
members.some(member => member.privileges === 'readAndWrite') &&
members.some(member => member.pendingEditor)
)
}, [members])
const haveAnyEditorsBeenDowngraded = useMemo(() => {
if (!isProjectOwner || !features) {
return false
}
if (features.collaborators === -1) {
return false
}
return members.some(member => member.pendingEditor)
}, [features, isProjectOwner, members])
const hasExceededCollaboratorLimit = useMemo(() => {
if (!isProjectOwner || !features) {
return false
}
if (features.collaborators === -1) {
return false
}
return (
members.filter(member => member.privileges === 'readAndWrite').length >
(features.collaborators ?? 1)
)
}, [features, isProjectOwner, members])
const sortedMembers = useMemo(() => {
return [
...members.filter(member => member.privileges === 'readAndWrite'),
...members.filter(member => member.pendingEditor),
...members.filter(
member => !member.pendingEditor && member.privileges !== 'readAndWrite'
),
]
}, [members])
return (
<>
{isProjectOwner ? (
<SendInvites
canAddCollaborators={canAddCollaborators}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
haveAnyEditorsBeenDowngraded={haveAnyEditorsBeenDowngraded}
somePendingEditorsResolved={somePendingEditorsResolved}
/>
) : (
<SendInvitesNotice />
)}
{isProjectOwner && <LinkSharing />}
<OwnerInfo />
{sortedMembers.map(member =>
isProjectOwner ? (
<EditMember
key={member._id}
member={member}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
hasBeenDowngraded={member.pendingEditor ?? false}
canAddCollaborators={canAddCollaborators}
/>
) : (
<ViewMember key={member._id} member={member} />
)
)}
{invites.map(invite => (
<Invite
key={invite._id}
invite={invite}
isProjectOwner={isProjectOwner}
/>
))}
{!getMeta('ol-ExposedSettings').recaptchaDisabled?.invite && (
<RecaptchaConditions />
)}
</>
)
}
@@ -1,129 +0,0 @@
import { useTranslation } from 'react-i18next'
import Icon from '@/shared/components/icon'
import { useEditorContext } from '@/shared/context/editor-context'
import { lazy, Suspense } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import OLButton from '@/features/ui/components/ol/ol-button'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { bsVersion } from '@/features/utils/bootstrap-5'
import { Spinner } from 'react-bootstrap-5'
const ReadOnlyTokenLink = lazy(() =>
import('./link-sharing').then(({ ReadOnlyTokenLink }) => ({
// re-export as default -- lazy can only handle default exports.
default: ReadOnlyTokenLink,
}))
)
const ShareModalBody = lazy(() => import('./share-modal-body'))
type ShareProjectModalContentProps = {
cancel: () => void
show: boolean
animation: boolean
inFlight: boolean
error: string | undefined
}
export default function ShareProjectModalContent({
show,
cancel,
animation,
inFlight,
error,
}: ShareProjectModalContentProps) {
const { t } = useTranslation()
const { isRestrictedTokenMember } = useEditorContext()
return (
<OLModal show={show} onHide={cancel} animation={animation}>
<OLModalHeader closeButton>
<OLModalTitle>{t('share_project')}</OLModalTitle>
</OLModalHeader>
<OLModalBody className="modal-body-share modal-link-share-new">
<div className="container-fluid">
<Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
{isRestrictedTokenMember ? (
<ReadOnlyTokenLink />
) : (
<ShareModalBody />
)}
</Suspense>
{error && (
<OLNotification
type="error"
content={<ErrorMessage error={error} />}
className="mb-0 mt-3"
/>
)}
</div>
</OLModalBody>
<OLModalFooter>
<div className={bsVersion({ bs3: 'pull-left', bs5: 'me-auto' })}>
{inFlight && (
<BootstrapVersionSwitcher
bs3={<Icon type="refresh" spin />}
bs5={
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
}
/>
)}
</div>
<ClickableElementEnhancer
onClick={cancel}
as={OLButton}
variant="secondary"
disabled={inFlight}
>
{t('close')}
</ClickableElementEnhancer>
</OLModalFooter>
</OLModal>
)
}
function ErrorMessage({ error }: Pick<ShareProjectModalContentProps, 'error'>) {
const { t } = useTranslation()
switch (error) {
case 'cannot_invite_non_user':
return <>{t('cannot_invite_non_user')}</>
case 'cannot_verify_user_not_robot':
return <>{t('cannot_verify_user_not_robot')}</>
case 'cannot_invite_self':
return <>{t('cannot_invite_self')}</>
case 'invalid_email':
return <>{t('invalid_email')}</>
case 'too_many_requests':
return <>{t('too_many_requests')}</>
case 'invite_expired':
return <>{t('invite_expired')}</>
case 'invite_resend_limit_hit':
return <>{t('invite_resend_limit_hit')}</>
default:
return <>{t('generic_something_went_wrong')}</>
}
}
@@ -1,179 +0,0 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react'
import ShareProjectModalContent from './share-project-modal-content'
import { useProjectContext } from '@/shared/context/project-context'
import { useSplitTestContext } from '@/shared/context/split-test-context'
import { sendMB } from '@/infrastructure/event-tracking'
import { ProjectContextUpdateValue } from '@/shared/context/types/project-context'
import { useEditorContext } from '@/shared/context/editor-context'
import customLocalStorage from '@/infrastructure/local-storage'
type ShareProjectContextValue = {
updateProject: (project: ProjectContextUpdateValue) => void
monitorRequest: <T extends Promise<unknown>>(request: () => T) => T
inFlight: boolean
setInFlight: React.Dispatch<
React.SetStateAction<ShareProjectContextValue['inFlight']>
>
error: string | undefined
setError: React.Dispatch<
React.SetStateAction<ShareProjectContextValue['error']>
>
}
const SHOW_MODAL_COOLDOWN_PERIOD = 24 * 60 * 60 * 1000 // 24 hours
const ShareProjectContext = createContext<ShareProjectContextValue | undefined>(
undefined
)
export function useShareProjectContext() {
const context = useContext(ShareProjectContext)
if (!context) {
throw new Error(
'useShareProjectContext is only available inside ShareProjectProvider'
)
}
return context
}
type ShareProjectModalProps = {
handleHide: () => void
show: boolean
handleOpen: () => void
animation?: boolean
}
const ShareProjectModal = React.memo(function ShareProjectModal({
handleHide,
show,
handleOpen,
animation = true,
}: ShareProjectModalProps) {
const [inFlight, setInFlight] =
useState<ShareProjectContextValue['inFlight']>(false)
const [error, setError] = useState<ShareProjectContextValue['error']>()
const project = useProjectContext()
const { isProjectOwner } = useEditorContext()
const { splitTestVariants } = useSplitTestContext()
// show the new share modal if project owner
// is over collaborator limit or has pending editors (once every 24 hours)
useEffect(() => {
const hasExceededCollaboratorLimit = () => {
if (!isProjectOwner || !project.features) {
return false
}
if (project.features.collaborators === -1) {
return false
}
return (
project.members.filter(member => member.privileges === 'readAndWrite')
.length > (project.features.collaborators ?? 1) ||
project.members.some(member => member.pendingEditor)
)
}
if (hasExceededCollaboratorLimit()) {
const localStorageKey = `last-shown-share-modal.${project._id}`
const lastShownShareModalTime =
customLocalStorage.getItem(localStorageKey)
if (
!lastShownShareModalTime ||
lastShownShareModalTime + SHOW_MODAL_COOLDOWN_PERIOD < Date.now()
) {
handleOpen()
customLocalStorage.setItem(localStorageKey, Date.now())
}
}
}, [project, isProjectOwner, handleOpen])
// send tracking event when the modal is opened
useEffect(() => {
if (show) {
sendMB('share-modal-opened', {
splitTestVariant: splitTestVariants['null-test-share-modal'],
project_id: project._id,
})
}
}, [splitTestVariants, project._id, show])
// reset error when the modal is opened
useEffect(() => {
if (show) {
setError(undefined)
}
}, [show])
// close the modal if not in flight
const cancel = useCallback(() => {
if (!inFlight) {
handleHide()
}
}, [handleHide, inFlight])
// update `error` and `inFlight` while sending a request
const monitorRequest = useCallback(request => {
setError(undefined)
setInFlight(true)
const promise = request()
promise.catch((error: { data?: Record<string, string> }) => {
setError(
error.data?.errorReason ||
error.data?.error ||
'generic_something_went_wrong'
)
})
promise.finally(() => {
setInFlight(false)
})
return promise
}, [])
// merge the new data with the old project data
const updateProject = useCallback(
data => Object.assign(project, data),
[project]
)
if (!project) {
return null
}
return (
<ShareProjectContext.Provider
value={{
updateProject,
monitorRequest,
inFlight,
setInFlight,
error,
setError,
}}
>
<ShareProjectModalContent
animation={animation}
cancel={cancel}
error={error}
inFlight={inFlight}
show={show}
/>
</ShareProjectContext.Provider>
)
})
export default ShareProjectModal
@@ -1,96 +0,0 @@
import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
import Icon from '@/shared/components/icon'
import { transferProjectOwnership } from '../../utils/api'
import { useProjectContext } from '@/shared/context/project-context'
import { useLocation } from '@/shared/hooks/use-location'
import OLModal, {
OLModalBody,
OLModalFooter,
OLModalHeader,
OLModalTitle,
} from '@/features/ui/components/ol/ol-modal'
import OLNotification from '@/features/ui/components/ol/ol-notification'
import OLButton from '@/features/ui/components/ol/ol-button'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
import { bsVersion } from '@/features/utils/bootstrap-5'
import { Spinner } from 'react-bootstrap-5'
export default function TransferOwnershipModal({ member, cancel }) {
const { t } = useTranslation()
const [inflight, setInflight] = useState(false)
const [error, setError] = useState(false)
const location = useLocation()
const { _id: projectId, name: projectName } = useProjectContext()
function confirm() {
setError(false)
setInflight(true)
transferProjectOwnership(projectId, member)
.then(() => {
location.reload()
})
.catch(() => {
setError(true)
setInflight(false)
})
}
return (
<OLModal show onHide={cancel}>
<OLModalHeader closeButton>
<OLModalTitle>{t('change_project_owner')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>
<p>
<Trans
i18nKey="project_ownership_transfer_confirmation_1"
values={{ user: member.email, project: projectName }}
components={[<strong key="strong-1" />, <strong key="strong-2" />]}
shouldUnescape
tOptions={{ interpolation: { escapeValue: true } }}
/>
</p>
<p>{t('project_ownership_transfer_confirmation_2')}</p>
{error && (
<OLNotification
type="error"
content={t('generic_something_went_wrong')}
className="mb-0 mt-3"
/>
)}
</OLModalBody>
<OLModalFooter>
<div className={bsVersion({ bs3: 'pull-left', bs5: 'me-auto' })}>
{inflight && (
<BootstrapVersionSwitcher
bs3={<Icon type="refresh" spin />}
bs5={
<Spinner
animation="border"
aria-hidden="true"
size="sm"
role="status"
/>
}
/>
)}
</div>
<OLButton variant="secondary" onClick={cancel} disabled={inflight}>
{t('cancel')}
</OLButton>
<OLButton variant="primary" onClick={confirm} disabled={inflight}>
{t('change_owner')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
TransferOwnershipModal.propTypes = {
member: PropTypes.object.isRequired,
cancel: PropTypes.func.isRequired,
}
@@ -1,34 +0,0 @@
import PropTypes from 'prop-types'
import MemberPrivileges from './member-privileges'
import Icon from '@/shared/components/icon'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import MaterialIcon from '@/shared/components/material-icon'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
export default function ViewMember({ member }) {
return (
<OLRow className="project-member">
<OLCol xs={8}>
<div className="project-member-email-icon">
<BootstrapVersionSwitcher
bs3={<Icon type="user" fw />}
bs5={<MaterialIcon type="person" />}
/>
<div className="email-warning">{member.email}</div>
</div>
</OLCol>
<OLCol xs={4} className="text-end">
<MemberPrivileges privileges={member.privileges} />
</OLCol>
</OLRow>
)
}
ViewMember.propTypes = {
member: PropTypes.shape({
_id: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
privileges: PropTypes.string.isRequired,
}).isRequired,
}
@@ -5,7 +5,7 @@ import { matchSorter } from 'match-sorter'
import { useCombobox } from 'downshift'
import classnames from 'classnames'
import Icon from '../../../shared/components/icon'
import Icon from '@/shared/components/icon'
import MaterialIcon from '@/shared/components/material-icon'
import Tag from '@/features/ui/components/bootstrap-5/tag'
import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu'
@@ -155,11 +155,13 @@ export default function SelectCollaborators({
}, [reset, isOpen, filteredOptions.length])
return (
<div className="tags-input">
<div className="tags-input tags-new">
{/* eslint-disable-next-line jsx-a11y/label-has-for */}
<label className="small" {...getLabelProps()}>
{t('share_with_your_collabs')}
&nbsp;
<strong>
{t('add_people')}
&nbsp;
</strong>
{loading && (
<BootstrapVersionSwitcher
bs3={<Icon type="refresh" spin />}
@@ -212,6 +214,7 @@ export default function SelectCollaborators({
onChange: e => {
setInputValue(e.target.value)
},
onClick: () => focusInput,
onKeyDown: event => {
switch (event.key) {
case 'Enter':
@@ -1,34 +0,0 @@
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '../../../shared/context/project-context'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
export default function SendInvitesNotice() {
const { publicAccessLevel } = useProjectContext()
return (
<OLRow className="public-access-level public-access-level-notice">
<OLCol className="text-center">
<AccessLevel level={publicAccessLevel} />
</OLCol>
</OLRow>
)
}
function AccessLevel({ level }) {
const { t } = useTranslation()
switch (level) {
case 'private':
return t('to_add_more_collaborators')
case 'tokenBased':
return t('to_change_access_permissions')
default:
return null
}
}
AccessLevel.propTypes = {
level: PropTypes.string,
}
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
import Notification from '@/shared/components/notification'
import { PublicAccessLevel } from '../../../../../../types/public-access-level'
import { PublicAccessLevel } from '../../../../../types/public-access-level'
import { useEditorContext } from '@/shared/context/editor-context'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
@@ -1,16 +1,39 @@
import AddCollaborators from './add-collaborators'
import AddCollaboratorsUpgrade from './add-collaborators-upgrade'
import CollaboratorsLimitUpgrade from './collaborators-limit-upgrade'
import AccessLevelsChanged from './access-levels-changed'
import PropTypes from 'prop-types'
import OLRow from '@/features/ui/components/ol/ol-row'
export default function SendInvites({ canAddCollaborators }) {
export default function SendInvites({
canAddCollaborators,
hasExceededCollaboratorLimit,
haveAnyEditorsBeenDowngraded,
somePendingEditorsResolved,
}) {
return (
<OLRow className="invite-controls">
{canAddCollaborators ? <AddCollaborators /> : <AddCollaboratorsUpgrade />}
{hasExceededCollaboratorLimit && !haveAnyEditorsBeenDowngraded && (
<AddCollaboratorsUpgrade />
)}
{haveAnyEditorsBeenDowngraded && (
<AccessLevelsChanged
somePendingEditorsResolved={somePendingEditorsResolved}
/>
)}
{!canAddCollaborators &&
!hasExceededCollaboratorLimit &&
!haveAnyEditorsBeenDowngraded && <CollaboratorsLimitUpgrade />}
<AddCollaborators readOnly={!canAddCollaborators} />
</OLRow>
)
}
SendInvites.propTypes = {
canAddCollaborators: PropTypes.bool,
hasExceededCollaboratorLimit: PropTypes.bool,
haveAnyEditorsBeenDowngraded: PropTypes.bool,
somePendingEditorsResolved: PropTypes.bool,
}
@@ -5,10 +5,10 @@ import SendInvites from './send-invites'
import ViewMember from './view-member'
import OwnerInfo from './owner-info'
import SendInvitesNotice from './send-invites-notice'
import { useEditorContext } from '../../../shared/context/editor-context'
import { useProjectContext } from '../../../shared/context/project-context'
import { useEditorContext } from '@/shared/context/editor-context'
import { useProjectContext } from '@/shared/context/project-context'
import { useMemo } from 'react'
import RecaptchaConditions from '../../../shared/components/recaptcha-conditions'
import RecaptchaConditions from '@/shared/components/recaptcha-conditions'
import getMeta from '@/utils/meta'
export default function ShareModalBody() {
@@ -26,18 +26,87 @@ export default function ShareModalBody() {
return true
}
return members.length + invites.length < (features.collaborators ?? 1)
const editorInvites = invites.filter(
invite => invite.privileges === 'readAndWrite'
).length
return (
members.filter(member => member.privileges === 'readAndWrite').length +
editorInvites <
(features.collaborators ?? 1)
)
}, [members, invites, features, isProjectOwner])
// determine if some but not all pending editors' permissions have been resolved,
// for moving between warning and info notification states etc.
const somePendingEditorsResolved = useMemo(() => {
return (
members.some(member => member.privileges === 'readAndWrite') &&
members.some(member => member.pendingEditor)
)
}, [members])
const haveAnyEditorsBeenDowngraded = useMemo(() => {
if (!isProjectOwner || !features) {
return false
}
if (features.collaborators === -1) {
return false
}
return members.some(member => member.pendingEditor)
}, [features, isProjectOwner, members])
const hasExceededCollaboratorLimit = useMemo(() => {
if (!isProjectOwner || !features) {
return false
}
if (features.collaborators === -1) {
return false
}
return (
members.filter(member => member.privileges === 'readAndWrite').length >
(features.collaborators ?? 1)
)
}, [features, isProjectOwner, members])
const sortedMembers = useMemo(() => {
return [
...members.filter(member => member.privileges === 'readAndWrite'),
...members.filter(member => member.pendingEditor),
...members.filter(
member => !member.pendingEditor && member.privileges !== 'readAndWrite'
),
]
}, [members])
return (
<>
{isProjectOwner ? (
<SendInvites
canAddCollaborators={canAddCollaborators}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
haveAnyEditorsBeenDowngraded={haveAnyEditorsBeenDowngraded}
somePendingEditorsResolved={somePendingEditorsResolved}
/>
) : (
<SendInvitesNotice />
)}
{isProjectOwner && <LinkSharing />}
<OwnerInfo />
{members.map(member =>
{sortedMembers.map(member =>
isProjectOwner ? (
<EditMember key={member._id} member={member} />
<EditMember
key={member._id}
member={member}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
hasBeenDowngraded={member.pendingEditor ?? false}
canAddCollaborators={canAddCollaborators}
/>
) : (
<ViewMember key={member._id} member={member} />
)
@@ -51,12 +120,6 @@ export default function ShareModalBody() {
/>
))}
{isProjectOwner ? (
<SendInvites canAddCollaborators={canAddCollaborators} />
) : (
<SendInvitesNotice />
)}
{!getMeta('ol-ExposedSettings').recaptchaDisabled?.invite && (
<RecaptchaConditions />
)}
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import Icon from '../../../shared/components/icon'
import Icon from '@/shared/components/icon'
import { useEditorContext } from '@/shared/context/editor-context'
import { lazy, Suspense } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
@@ -50,7 +50,7 @@ export default function ShareProjectModalContent({
<OLModalTitle>{t('share_project')}</OLModalTitle>
</OLModalHeader>
<OLModalBody className="modal-body-share">
<OLModalBody className="modal-body-share modal-link-share-new">
<div className="container-fluid">
<Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
{isRestrictedTokenMember ? (
@@ -6,13 +6,15 @@ import React, {
useState,
} from 'react'
import ShareProjectModalContent from './share-project-modal-content'
import { useProjectContext } from '../../../shared/context/project-context'
import { useSplitTestContext } from '../../../shared/context/split-test-context'
import { sendMB } from '../../../infrastructure/event-tracking'
import { Project } from '../../../../../types/project'
import { useProjectContext } from '@/shared/context/project-context'
import { useSplitTestContext } from '@/shared/context/split-test-context'
import { sendMB } from '@/infrastructure/event-tracking'
import { ProjectContextUpdateValue } from '@/shared/context/types/project-context'
import { useEditorContext } from '@/shared/context/editor-context'
import customLocalStorage from '@/infrastructure/local-storage'
type ShareProjectContextValue = {
updateProject: (project: Project) => void
updateProject: (project: ProjectContextUpdateValue) => void
monitorRequest: <T extends Promise<unknown>>(request: () => T) => T
inFlight: boolean
setInFlight: React.Dispatch<
@@ -24,6 +26,8 @@ type ShareProjectContextValue = {
>
}
const SHOW_MODAL_COOLDOWN_PERIOD = 24 * 60 * 60 * 1000 // 24 hours
const ShareProjectContext = createContext<ShareProjectContextValue | undefined>(
undefined
)
@@ -43,12 +47,14 @@ export function useShareProjectContext() {
type ShareProjectModalProps = {
handleHide: () => void
show: boolean
handleOpen: () => void
animation?: boolean
}
const ShareProjectModal = React.memo(function ShareProjectModal({
handleHide,
show,
handleOpen,
animation = true,
}: ShareProjectModalProps) {
const [inFlight, setInFlight] =
@@ -56,9 +62,42 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
const [error, setError] = useState<ShareProjectContextValue['error']>()
const project = useProjectContext()
const { isProjectOwner } = useEditorContext()
const { splitTestVariants } = useSplitTestContext()
// show the new share modal if project owner
// is over collaborator limit or has pending editors (once every 24 hours)
useEffect(() => {
const hasExceededCollaboratorLimit = () => {
if (!isProjectOwner || !project.features) {
return false
}
if (project.features.collaborators === -1) {
return false
}
return (
project.members.filter(member => member.privileges === 'readAndWrite')
.length > (project.features.collaborators ?? 1) ||
project.members.some(member => member.pendingEditor)
)
}
if (hasExceededCollaboratorLimit()) {
const localStorageKey = `last-shown-share-modal.${project._id}`
const lastShownShareModalTime =
customLocalStorage.getItem(localStorageKey)
if (
!lastShownShareModalTime ||
lastShownShareModalTime + SHOW_MODAL_COOLDOWN_PERIOD < Date.now()
) {
handleOpen()
customLocalStorage.setItem(localStorageKey, Date.now())
}
}
}, [project, isProjectOwner, handleOpen])
// send tracking event when the modal is opened
useEffect(() => {
if (show) {
@@ -1,10 +1,10 @@
import { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
import Icon from '../../../shared/components/icon'
import Icon from '@/shared/components/icon'
import { transferProjectOwnership } from '../utils/api'
import { useProjectContext } from '../../../shared/context/project-context'
import { useLocation } from '../../../shared/hooks/use-location'
import { useProjectContext } from '@/shared/context/project-context'
import { useLocation } from '@/shared/hooks/use-location'
import OLModal, {
OLModalBody,
OLModalFooter,
@@ -1,13 +1,24 @@
import PropTypes from 'prop-types'
import MemberPrivileges from './member-privileges'
import Icon from '@/shared/components/icon'
import OLRow from '@/features/ui/components/ol/ol-row'
import OLCol from '@/features/ui/components/ol/ol-col'
import MaterialIcon from '@/shared/components/material-icon'
import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
export default function ViewMember({ member }) {
return (
<OLRow className="project-member">
<OLCol xs={7}>{member.email}</OLCol>
<OLCol xs={3}>
<OLCol xs={8}>
<div className="project-member-email-icon">
<BootstrapVersionSwitcher
bs3={<Icon type="user" fw />}
bs5={<MaterialIcon type="person" />}
/>
<div className="email-warning">{member.email}</div>
</div>
</OLCol>
<OLCol xs={4} className="text-end">
<MemberPrivileges privileges={member.privileges} />
</OLCol>
</OLRow>
-5
View File
@@ -85,7 +85,6 @@
"add_error_assist_annual_to_your_projects": "Add Error Assist Annual <sparkle/> to your projects and get unlimited AI help to fix LaTeX errors faster.",
"add_error_assist_to_your_projects": "Add Error Assist <sparkle/> to your projects and get unlimited AI help to fix LaTeX errors faster.",
"add_files": "Add Files",
"add_more_collaborators": "Add more collaborators",
"add_more_editors": "Add more editors",
"add_more_managers": "Add more managers",
"add_more_members": "Add more members",
@@ -1148,7 +1147,6 @@
"link_accounts_and_add_email": "Link Accounts and Add Email",
"link_institutional_email_get_started": "Link an institutional email address to your account to get started.",
"link_sharing": "Link sharing",
"link_sharing_is_off": "Link sharing is off, only invited users can view this project.",
"link_sharing_is_off_short": "Link sharing is off",
"link_sharing_is_on": "Link sharing is on",
"link_to_github": "Link to your GitHub account",
@@ -1315,7 +1313,6 @@
"need_more_than_x_licenses": "Need more than __x__ licenses?",
"need_to_add_new_primary_before_remove": "Youll need to add a new primary email address before you can remove this one.",
"need_to_leave": "Need to leave?",
"need_to_upgrade_for_more_collabs": "You need to upgrade your account to add more collaborators",
"new_compile_domain_notice": "Weve recently migrated PDF downloads to a new domain. Something might be blocking your browser from accessing that new domain, <0>__compilesUserContentDomain__</0>. This could be caused by network blocking or a strict browser plugin rule. Please follow our <1>troubleshooting guide</1>.",
"new_file": "New file",
"new_folder": "New folder",
@@ -1719,7 +1716,6 @@
"remove": "Remove",
"remove_access": "Remove access",
"remove_add_on": "Remove add-on",
"remove_collaborator": "Remove collaborator",
"remove_from_group": "Remove from group",
"remove_link": "Remove link",
"remove_manager": "Remove manager",
@@ -1916,7 +1912,6 @@
"setup_another_account_under_a_personal_email_address": "Set up another Overleaf account under a personal email address.",
"share": "Share",
"share_project": "Share Project",
"share_with_your_collabs": "Share with your collaborators",
"shared_with_you": "Shared with you",
"sharelatex_beta_program": "__appName__ Beta Program",
"shortcut_to_open_advanced_reference_search": "(<strong>__ctrlSpace__</strong> or <strong>__altSpace__</strong>)",
@@ -1,14 +1,9 @@
import { expect } from 'chai'
import sinon from 'sinon'
import {
screen,
fireEvent,
render,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react'
import { screen, fireEvent, render, waitFor } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import userEvent from '@testing-library/user-event'
import * as bootstrapUtils from '@/features/utils/bootstrap-5'
import ShareProjectModal from '../../../../../frontend/js/features/share-project-modal/components/share-project-modal'
import {
@@ -22,6 +17,15 @@ import {
} from '../../../helpers/editor-providers'
import * as useLocationModule from '../../../../../frontend/js/shared/hooks/use-location'
async function changePrivilegeLevel(screen, { current, next }) {
const select = screen.getByDisplayValue(current)
await fireEvent.click(select)
const option = screen.getByRole('option', {
name: next,
})
await fireEvent.click(option)
}
describe('<ShareProjectModal/>', function () {
const project = {
_id: 'test-project',
@@ -91,6 +95,9 @@ describe('<ShareProjectModal/>', function () {
replace: sinon.stub(),
reload: sinon.stub(),
})
this.isBootstrap5Stub = sinon
.stub(bootstrapUtils, 'isBootstrap5')
.returns(true)
fetchMock.get('/user/contacts', { contacts })
window.metaAttributesCache.set('ol-user', { allowedFreeTrial: true })
window.metaAttributesCache.set('ol-showUpgradePrompt', true)
@@ -98,6 +105,7 @@ describe('<ShareProjectModal/>', function () {
afterEach(function () {
this.locationStub.restore()
this.isBootstrap5Stub.restore()
fetchMock.restore()
cleanUpContext()
})
@@ -134,9 +142,7 @@ describe('<ShareProjectModal/>', function () {
scope: { project: { ...project, publicAccesLevel: 'private' } },
})
await screen.findByText(
'Link sharing is off, only invited users can view this project.'
)
await screen.findByText('Link sharing is off')
await screen.findByRole('button', { name: 'Turn on link sharing' })
expect(screen.queryByText('Anyone with this link can view this project')).to
@@ -196,7 +202,7 @@ describe('<ShareProjectModal/>', function () {
await screen.findByRole('button', { name: 'Make Private' })
})
it('hides actions from non-project-owners', async function () {
it('displays actions for project-owners', async function () {
const invites = [
{
_id: 'invited-author',
@@ -222,8 +228,17 @@ describe('<ShareProjectModal/>', function () {
await screen.findByRole('button', { name: 'Turn off link sharing' })
await screen.findByRole('button', { name: 'Resend' })
})
it('hides actions from non-project-owners when link sharing on', async function () {
const invites = [
{
_id: 'invited-author',
email: 'invited-author@example.com',
privileges: 'readAndWrite',
},
]
// render as non-project-owner, link sharing on: actions should be missing and message should be present
render(
<EditorProviders
scope={{
@@ -251,8 +266,17 @@ describe('<ShareProjectModal/>', function () {
expect(screen.queryByRole('button', { name: 'Turn on link sharing' })).to.be
.null
expect(screen.queryByRole('button', { name: 'Resend' })).to.be.null
})
it('hides actions from non-project-owners when link sharing off', async function () {
const invites = [
{
_id: 'invited-author',
email: 'invited-author@example.com',
privileges: 'readAndWrite',
},
]
// render as non-project-owner, link sharing off: actions should be missing and message should be present
render(
<EditorProviders
scope={{
@@ -342,6 +366,7 @@ describe('<ShareProjectModal/>', function () {
const projectOwnerEmail = USER_EMAIL
await screen.findByText(projectOwnerEmail)
expect(screen.queryAllByText(projectOwnerEmail)).to.have.length(1)
expect(screen.queryAllByText('member-author@example.com')).to.have.length(1)
expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
@@ -455,14 +480,12 @@ describe('<ShareProjectModal/>', function () {
name: 'Close',
})
expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
expect(
await screen.findAllByText('member-viewer@example.com')
).to.have.length(1)
const select = screen.getByDisplayValue('Read only')
await fireEvent.change(select, { target: { value: 'readAndWrite' } })
await changePrivilegeLevel(screen, { current: 'Viewer', next: 'Editor' })
const changeButton = screen.getByRole('button', { name: 'Change' })
fireEvent.click(changeButton)
await waitFor(() => expect(closeButton.disabled).to.be.true)
const { body } = fetchMock.lastOptions()
@@ -493,22 +516,23 @@ describe('<ShareProjectModal/>', function () {
},
})
expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
expect(
await screen.findAllByText('member-viewer@example.com')
).to.have.length(1)
await changePrivilegeLevel(screen, {
current: 'Viewer',
next: 'Remove access',
})
const removeButton = screen.getByRole('button', {
name: 'Remove collaborator',
name: 'Change',
})
fireEvent.click(removeButton)
const url = fetchMock.lastUrl()
expect(url).to.equal('/project/test-project/users/member-viewer')
expect(fetchMock.done()).to.be.true
await waitForElementToBeRemoved(() =>
screen.queryByText('member-viewer@example.com')
)
})
it('changes member privileges to owner with confirmation', async function () {
@@ -532,13 +556,13 @@ describe('<ShareProjectModal/>', function () {
},
})
await screen.findByText('member-viewer@example.com')
expect(screen.queryAllByText('member-viewer@example.com')).to.have.length(1)
const select = screen.getByDisplayValue('Read only')
fireEvent.change(select, { target: { value: 'owner' } })
const changeButton = screen.getByRole('button', { name: 'Change' })
await fireEvent.click(changeButton)
await changePrivilegeLevel(screen, {
current: 'Viewer',
next: 'Make owner',
})
screen.getByText((_, node) => {
return (
@@ -569,9 +593,7 @@ describe('<ShareProjectModal/>', function () {
},
})
const [inputElement] = await screen.findAllByLabelText(
'Share with your collaborators'
)
const [inputElement] = await screen.findAllByLabelText('Add people')
// loading contacts
await waitFor(() => {
@@ -618,7 +640,7 @@ describe('<ShareProjectModal/>', function () {
const privilegesElement = screen.getByDisplayValue('Can edit')
fireEvent.change(privilegesElement, { target: { value: 'readOnly' } })
const submitButton = screen.getByRole('button', { name: 'Share' })
const submitButton = screen.getByRole('button', { name: 'Invite' })
await userEvent.click(submitButton)
let calls
@@ -667,10 +689,13 @@ describe('<ShareProjectModal/>', function () {
},
})
expect(screen.queryByLabelText('Share with your collaborators')).to.be.null
await screen.findByText('Add more editors')
expect(screen.getByRole('option', { name: 'Can edit' }).disabled).to.be.true
expect(screen.getByRole('option', { name: 'Can view' }).disabled).to.be
.false
screen.getByText(
/You need to upgrade your account to add more collaborators/
/Upgrade to add more editors and access collaboration features like track changes and full project history/
)
})
@@ -689,11 +714,9 @@ describe('<ShareProjectModal/>', function () {
expect(fetchMock.called('express:/user/contacts')).to.be.true
})
const [inputElement] = await screen.findAllByLabelText(
'Share with your collaborators'
)
const [inputElement] = await screen.findAllByLabelText('Add people')
const submitButton = screen.getByRole('button', { name: 'Share' })
const submitButton = screen.getByRole('button', { name: 'Invite' })
const respondWithError = async function (errorReason) {
fireEvent.focus(inputElement)
@@ -748,9 +771,7 @@ describe('<ShareProjectModal/>', function () {
},
})
await screen.findByText(
'Link sharing is off, only invited users can view this project.'
)
await screen.findByText('Link sharing is off')
const enableButton = await screen.findByRole('button', {
name: 'Turn on link sharing',
@@ -791,9 +812,7 @@ describe('<ShareProjectModal/>', function () {
})
// watchCallbacks.project({ ...project, publicAccesLevel: 'private' })
await screen.findByText(
'Link sharing is off, only invited users can view this project.'
)
await screen.findByText('Link sharing is off')
})
it('avoids selecting unmatched contact', async function () {
@@ -801,9 +820,7 @@ describe('<ShareProjectModal/>', function () {
scope: { project },
})
const [inputElement] = await screen.findAllByLabelText(
'Share with your collaborators'
)
const [inputElement] = await screen.findAllByLabelText('Add people')
// Wait for contacts to load
await waitFor(() => {
@@ -838,7 +855,7 @@ describe('<ShareProjectModal/>', function () {
// Pressing Tab should add the entered item
fireEvent.keyDown(inputElement, { key: 'Tab', code: 'Tab' })
await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
1
)
})
@@ -846,7 +863,7 @@ describe('<ShareProjectModal/>', function () {
// Blurring the input should not add another contact
fireEvent.blur(inputElement)
await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
1
)
})
@@ -857,9 +874,7 @@ describe('<ShareProjectModal/>', function () {
scope: { project },
})
const [inputElement] = await screen.findAllByLabelText(
'Share with your collaborators'
)
const [inputElement] = await screen.findAllByLabelText('Add people')
// Wait for contacts to load
await waitFor(() => {
@@ -876,14 +891,14 @@ describe('<ShareProjectModal/>', function () {
})
// No items should be added yet
expect(screen.queryByRole('button', { name: 'Remove' })).to.be.null
expect(screen.queryByRole('button', { name: /Remove/ })).to.be.null
// Click anywhere on the form to blur the input
await userEvent.click(screen.getByRole('dialog'))
// The contact should be added
await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
1
)
})
@@ -894,9 +909,7 @@ describe('<ShareProjectModal/>', function () {
scope: { project },
})
const [inputElement] = await screen.findAllByLabelText(
'Share with your collaborators'
)
const [inputElement] = await screen.findAllByLabelText('Add people')
// Wait for contacts to load
await waitFor(() => {
@@ -919,7 +932,7 @@ describe('<ShareProjectModal/>', function () {
// The contact should be added
await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
1
)
})
@@ -930,9 +943,7 @@ describe('<ShareProjectModal/>', function () {
scope: { project },
})
const [inputElement] = await screen.findAllByLabelText(
'Share with your collaborators'
)
const [inputElement] = await screen.findAllByLabelText('Add people')
// Wait for contacts to load
await waitFor(() => {
@@ -954,10 +965,10 @@ describe('<ShareProjectModal/>', function () {
await userEvent.click(screen.getByRole('dialog'))
// Remove the just-added collaborator
await userEvent.click(screen.getByRole('button', { name: 'Remove' }))
await userEvent.click(screen.getByRole('button', { name: /Remove/ }))
// Remove button should now be gone
expect(screen.queryByRole('button', { name: 'Remove' })).to.be.null
expect(screen.queryByRole('button', { name: /Remove/ })).to.be.null
// Add the same collaborator again
await userEvent.type(inputElement, 'pto')
@@ -975,7 +986,7 @@ describe('<ShareProjectModal/>', function () {
// The contact should be added
await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Remove' })).to.have.length(
expect(screen.getAllByRole('button', { name: /Remove/ })).to.have.length(
1
)
})