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:
@@ -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
-3
@@ -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)
|
||||
|
||||
+61
-91
@@ -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" />}
|
||||
/>
|
||||
|
||||
{t('unlimited_projects')}
|
||||
</li>
|
||||
<li>
|
||||
<BootstrapVersionSwitcher
|
||||
bs3={<Icon type="check" />}
|
||||
bs5={<MaterialIcon type="check" className="align-text-bottom" />}
|
||||
/>
|
||||
|
||||
{t('collabs_per_proj', {
|
||||
collabcount: 'Multiple',
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
<BootstrapVersionSwitcher
|
||||
bs3={<Icon type="check" />}
|
||||
bs5={<MaterialIcon type="check" className="align-text-bottom" />}
|
||||
/>
|
||||
|
||||
{t('full_doc_history')}
|
||||
</li>
|
||||
<li>
|
||||
<BootstrapVersionSwitcher
|
||||
bs3={<Icon type="check" />}
|
||||
bs5={<MaterialIcon type="check" className="align-text-bottom" />}
|
||||
/>
|
||||
|
||||
{t('sync_to_dropbox')}
|
||||
</li>
|
||||
<li>
|
||||
<BootstrapVersionSwitcher
|
||||
bs3={<Icon type="check" />}
|
||||
bs5={<MaterialIcon type="check" className="align-text-bottom" />}
|
||||
/>
|
||||
|
||||
{t('sync_to_github')}
|
||||
</li>
|
||||
<li>
|
||||
<BootstrapVersionSwitcher
|
||||
bs3={<Icon type="check" />}
|
||||
bs5={<MaterialIcon type="check" className="align-text-bottom" />}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
+27
-10
@@ -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> </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')}
|
||||
|
||||
<OLButton
|
||||
variant="link"
|
||||
className="btn-inline-link"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('change_or_cancel-cancel')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
ChangePrivilegesActions.propTypes = {
|
||||
handleReset: PropTypes.func.isRequired,
|
||||
}
|
||||
+5
-3
@@ -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
-1
@@ -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')}
|
||||
.
|
||||
@@ -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> </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> </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>
|
||||
|
||||
-74
@@ -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>
|
||||
)
|
||||
}
|
||||
-197
@@ -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> </span>
|
||||
<ClickableElementEnhancer
|
||||
as={OLButton}
|
||||
onClick={handleSubmit}
|
||||
variant="primary"
|
||||
>
|
||||
{t('invite')}
|
||||
</ClickableElementEnhancer>
|
||||
</div>
|
||||
</OLFormGroup>
|
||||
</OLForm>
|
||||
)
|
||||
}
|
||||
|
||||
AddCollaborators.propTypes = {
|
||||
readOnly: PropTypes.bool,
|
||||
}
|
||||
-142
@@ -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')}
|
||||
.
|
||||
{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,
|
||||
}
|
||||
-325
@@ -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> </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> </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> </span>
|
||||
<OLButton
|
||||
variant="link"
|
||||
className="btn-inline-link"
|
||||
onClick={() => setAccessLevel('private')}
|
||||
disabled={inflight}
|
||||
>
|
||||
{t('turn_off_link_sharing')}
|
||||
</OLButton>
|
||||
<span> </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> </span>
|
||||
<OLButton
|
||||
variant="link"
|
||||
className="btn-inline-link"
|
||||
onClick={() => setAccessLevel('private')}
|
||||
disabled={inflight}
|
||||
>
|
||||
{t('make_private')}
|
||||
</OLButton>
|
||||
<span> </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>
|
||||
)
|
||||
}
|
||||
-23
@@ -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,
|
||||
}
|
||||
-29
@@ -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>
|
||||
)
|
||||
}
|
||||
-419
@@ -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')}
|
||||
|
||||
</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 />
|
||||
|
||||
{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,
|
||||
}
|
||||
-39
@@ -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,
|
||||
}
|
||||
-128
@@ -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 />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
-129
@@ -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')}</>
|
||||
}
|
||||
}
|
||||
-179
@@ -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
|
||||
-96
@@ -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,
|
||||
}
|
||||
-34
@@ -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,
|
||||
}
|
||||
+7
-4
@@ -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')}
|
||||
|
||||
<strong>
|
||||
{t('add_people')}
|
||||
|
||||
</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':
|
||||
|
||||
-34
@@ -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
-1
@@ -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,
|
||||
}
|
||||
|
||||
+75
-12
@@ -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 />
|
||||
)}
|
||||
|
||||
+2
-2
@@ -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 ? (
|
||||
|
||||
+44
-5
@@ -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) {
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
@@ -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": "You’ll 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": "We’ve 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>)",
|
||||
|
||||
+78
-67
@@ -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
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user