first commit
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import { upgradePlan } from '@/main/account-upgrade'
|
||||
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'
|
||||
|
||||
type AccessLevelsChangedProps = {
|
||||
somePendingEditorsResolved: boolean
|
||||
}
|
||||
export default function AccessLevelsChanged({
|
||||
somePendingEditorsResolved,
|
||||
}: AccessLevelsChangedProps) {
|
||||
const { t } = useTranslation()
|
||||
const { features } = useProjectContext()
|
||||
const user = useUserContext()
|
||||
|
||||
return (
|
||||
<div className="add-collaborators-upgrade">
|
||||
<Notification
|
||||
isActionBelowContent
|
||||
type={somePendingEditorsResolved ? 'info' : 'warning'}
|
||||
title={
|
||||
somePendingEditorsResolved
|
||||
? t('select_access_levels')
|
||||
: t('access_levels_changed')
|
||||
}
|
||||
content={
|
||||
somePendingEditorsResolved ? (
|
||||
<p>{t('your_project_exceeded_collaborator_limit')}</p>
|
||||
) : (
|
||||
<p>
|
||||
{t('this_project_exceeded_collaborator_limit')}{' '}
|
||||
{t('you_can_select_or_invite_collaborator', {
|
||||
count: features.collaborators,
|
||||
})}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
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 { Select } from '@/shared/components/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, features } = 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 !== 'readOnly') {
|
||||
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)
|
||||
}
|
||||
|
||||
const role = data?.invite?.privileges
|
||||
const membersAndInvites = (members || []).concat(invites || [])
|
||||
const previousEditorsAmount = membersAndInvites.filter(
|
||||
member => member.privileges === 'readAndWrite'
|
||||
).length
|
||||
const previousReviewersAmount = membersAndInvites.filter(
|
||||
member => member.privileges === 'review'
|
||||
).length
|
||||
const previousViewersAmount = membersAndInvites.filter(
|
||||
member => member.privileges === 'readOnly'
|
||||
).length
|
||||
|
||||
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,
|
||||
role,
|
||||
previousEditorsAmount,
|
||||
previousReviewersAmount,
|
||||
previousViewersAmount,
|
||||
newEditorsAmount:
|
||||
role === 'readAndWrite'
|
||||
? previousEditorsAmount + 1
|
||||
: previousEditorsAmount,
|
||||
newReviewersAmount:
|
||||
role === 'review'
|
||||
? previousReviewersAmount + 1
|
||||
: previousReviewersAmount,
|
||||
newViewersAmount:
|
||||
role === 'readOnly'
|
||||
? previousViewersAmount + 1
|
||||
: previousViewersAmount,
|
||||
})
|
||||
} 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,
|
||||
])
|
||||
|
||||
const privilegeOptions = useMemo(() => {
|
||||
const options = [
|
||||
{
|
||||
key: 'readAndWrite',
|
||||
label: t('editor'),
|
||||
},
|
||||
]
|
||||
|
||||
if (getMeta('ol-isReviewerRoleEnabled')) {
|
||||
options.push({
|
||||
key: 'review',
|
||||
label: t('reviewer'),
|
||||
description: !features.trackChanges
|
||||
? t('comment_only_upgrade_for_track_changes')
|
||||
: null,
|
||||
})
|
||||
}
|
||||
|
||||
options.push({
|
||||
key: 'readOnly',
|
||||
label: t('viewer'),
|
||||
})
|
||||
|
||||
return options
|
||||
}, [features.trackChanges, t])
|
||||
|
||||
return (
|
||||
<OLForm className="add-collabs">
|
||||
<OLFormGroup>
|
||||
<SelectCollaborators
|
||||
loading={!nonMemberContacts}
|
||||
options={nonMemberContacts || []}
|
||||
placeholder="Email, comma separated"
|
||||
multipleSelectionProps={multipleSelectionProps}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
|
||||
<OLFormGroup>
|
||||
<div className="pull-right add-collaborator-controls">
|
||||
<Select
|
||||
dataTestId="add-collaborator-select"
|
||||
items={privilegeOptions}
|
||||
itemToKey={item => item.key}
|
||||
itemToString={item => item.label}
|
||||
itemToSubtitle={item => item.description || ''}
|
||||
itemToDisabled={item => readOnly && item.key !== 'readOnly'}
|
||||
selected={privilegeOptions.find(
|
||||
option => option.key === privileges
|
||||
)}
|
||||
onSelectedItemChanged={item => setPrivileges(item.key)}
|
||||
/>
|
||||
<ClickableElementEnhancer
|
||||
as={OLButton}
|
||||
onClick={handleSubmit}
|
||||
variant="primary"
|
||||
>
|
||||
{t('invite')}
|
||||
</ClickableElementEnhancer>
|
||||
</div>
|
||||
</OLFormGroup>
|
||||
</OLForm>
|
||||
)
|
||||
}
|
||||
|
||||
AddCollaborators.propTypes = {
|
||||
readOnly: PropTypes.bool,
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import { upgradePlan } from '@/main/account-upgrade'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import StartFreeTrialButton from '@/shared/components/start-free-trial-button'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import addMoreEditorsImage from '../images/add-more-editors.svg'
|
||||
|
||||
export default function CollaboratorsLimitUpgrade() {
|
||||
const { t } = useTranslation()
|
||||
const user = useUserContext()
|
||||
|
||||
return (
|
||||
<div className="invite-warning">
|
||||
<Notification
|
||||
type="info"
|
||||
customIcon={<img src={addMoreEditorsImage} alt="" aria-hidden="true" />}
|
||||
title={t('add_more_collaborators')}
|
||||
content={
|
||||
<p>
|
||||
{t(
|
||||
'upgrade_to_add_more_collaborators_and_access_collaboration_features'
|
||||
)}
|
||||
</p>
|
||||
}
|
||||
isActionBelowContent
|
||||
action={
|
||||
user.allowedFreeTrial ? (
|
||||
<StartFreeTrialButton
|
||||
buttonProps={{ variant: 'premium' }}
|
||||
source="project-sharing"
|
||||
variant="limit"
|
||||
>
|
||||
{t('upgrade')}
|
||||
</StartFreeTrialButton>
|
||||
) : (
|
||||
<OLButton
|
||||
variant="premium"
|
||||
onClick={() => {
|
||||
upgradePlan('project-sharing')
|
||||
}}
|
||||
>
|
||||
{t('upgrade')}
|
||||
</OLButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useShareProjectContext } from './share-project-modal'
|
||||
import TransferOwnershipModal from './transfer-ownership-modal'
|
||||
import { removeMemberFromProject, updateMember } from '../utils/api'
|
||||
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 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'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
||||
import { upgradePlan } from '@/main/account-upgrade'
|
||||
|
||||
type PermissionsOption = PermissionsLevel | 'removeAccess' | 'downgraded'
|
||||
|
||||
type EditMemberProps = {
|
||||
member: ProjectContextMember
|
||||
hasExceededCollaboratorLimit: boolean
|
||||
hasBeenDowngraded: boolean
|
||||
canAddCollaborators: boolean
|
||||
isReviewerOnFreeProject?: boolean
|
||||
}
|
||||
|
||||
type Privilege = {
|
||||
key: PermissionsOption
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function EditMember({
|
||||
member,
|
||||
hasExceededCollaboratorLimit,
|
||||
hasBeenDowngraded,
|
||||
canAddCollaborators,
|
||||
isReviewerOnFreeProject,
|
||||
}: EditMemberProps) {
|
||||
const [privileges, setPrivileges] = useState<PermissionsOption>(
|
||||
member.privileges
|
||||
)
|
||||
const [confirmingOwnershipTransfer, setConfirmingOwnershipTransfer] =
|
||||
useState(false)
|
||||
const [privilegeChangePending, setPrivilegeChangePending] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
|
||||
// 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, invites } = useProjectContext()
|
||||
const user = useUserContext()
|
||||
|
||||
// Immediately commit this change if it's lower impact (eg. editor > viewer)
|
||||
// but show a confirmation button for removing access
|
||||
function handlePrivilegeChange(newPrivileges: PermissionsOption) {
|
||||
sendMB('collaborator-role-change', {
|
||||
previousMode: member.privileges,
|
||||
newMode: newPrivileges,
|
||||
ownerId: user.id,
|
||||
})
|
||||
|
||||
setPrivileges(newPrivileges)
|
||||
if (newPrivileges !== 'removeAccess') {
|
||||
commitPrivilegeChange(newPrivileges)
|
||||
} else {
|
||||
setPrivilegeChangePending(true)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldWarnMember() {
|
||||
return (
|
||||
hasExceededCollaboratorLimit &&
|
||||
['readAndWrite', 'review'].includes(privileges)
|
||||
)
|
||||
}
|
||||
|
||||
function commitPrivilegeChange(newPrivileges: PermissionsOption) {
|
||||
setPrivileges(newPrivileges)
|
||||
setPrivilegeChangePending(false)
|
||||
|
||||
if (newPrivileges === 'owner') {
|
||||
setConfirmingOwnershipTransfer(true)
|
||||
} else if (newPrivileges === 'removeAccess') {
|
||||
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,
|
||||
})
|
||||
}
|
||||
)
|
||||
} else if (
|
||||
newPrivileges === 'readAndWrite' ||
|
||||
newPrivileges === 'review' ||
|
||||
newPrivileges === 'readOnly'
|
||||
) {
|
||||
monitorRequest(() =>
|
||||
updateMember(projectId, member, {
|
||||
privilegeLevel: newPrivileges,
|
||||
})
|
||||
).then(() => {
|
||||
updateProject({
|
||||
members: members.map(item =>
|
||||
item._id === member._id ? { ...item, newPrivileges } : item
|
||||
),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmingOwnershipTransfer) {
|
||||
return (
|
||||
<TransferOwnershipModal
|
||||
member={member}
|
||||
cancel={() => {
|
||||
setConfirmingOwnershipTransfer(false)
|
||||
setPrivileges(member.privileges)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const confirmRemoval =
|
||||
privileges !== member.privileges && privilegeChangePending
|
||||
|
||||
return (
|
||||
<form
|
||||
id="share-project-form"
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
if (privilegeChangePending) {
|
||||
commitPrivilegeChange(privileges)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OLFormGroup className="project-member row">
|
||||
<OLCol xs={8}>
|
||||
<div className="project-member-email-icon">
|
||||
<MaterialIcon
|
||||
type={
|
||||
shouldWarnMember() ||
|
||||
member.pendingEditor ||
|
||||
member.pendingReviewer
|
||||
? 'warning'
|
||||
: 'person'
|
||||
}
|
||||
className={
|
||||
shouldWarnMember() ||
|
||||
member.pendingEditor ||
|
||||
member.pendingReviewer
|
||||
? 'project-member-warning'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<div className="email-warning">
|
||||
{member.email}
|
||||
{member.pendingEditor && (
|
||||
<div className="subtitle">{t('view_only_downgraded')}</div>
|
||||
)}
|
||||
{member.pendingReviewer && (
|
||||
<div className="subtitle">
|
||||
{t('view_only_reviewer_downgraded')}
|
||||
</div>
|
||||
)}
|
||||
{shouldWarnMember() && (
|
||||
<div className="subtitle">
|
||||
{t('will_lose_edit_access_on_date', {
|
||||
date: linkSharingEnforcementDate,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isReviewerOnFreeProject && (
|
||||
<div className="small">
|
||||
<Trans
|
||||
i18nKey="comment_only_upgrade_to_enable_track_changes"
|
||||
components={[
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
<OLButton
|
||||
variant="link"
|
||||
className="btn-inline-link"
|
||||
onClick={() => upgradePlan('track-changes')}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</OLCol>
|
||||
|
||||
<OLCol xs={4} className="project-member-actions">
|
||||
{confirmRemoval && (
|
||||
<ChangePrivilegesActions
|
||||
handleReset={() => setPrivileges(member.privileges)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="project-member-select">
|
||||
{hasBeenDowngraded && !confirmRemoval && (
|
||||
<MaterialIcon type="warning" className="project-member-warning" />
|
||||
)}
|
||||
|
||||
<SelectPrivilege
|
||||
value={privileges}
|
||||
handleChange={value => {
|
||||
if (value) {
|
||||
handlePrivilegeChange(value.key)
|
||||
}
|
||||
}}
|
||||
hasBeenDowngraded={hasBeenDowngraded && !confirmRemoval}
|
||||
canAddCollaborators={canAddCollaborators}
|
||||
/>
|
||||
</div>
|
||||
</OLCol>
|
||||
</OLFormGroup>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
EditMember.propTypes = {
|
||||
member: PropTypes.shape({
|
||||
_id: PropTypes.string.isRequired,
|
||||
email: PropTypes.string.isRequired,
|
||||
privileges: PropTypes.string.isRequired,
|
||||
}),
|
||||
hasExceededCollaboratorLimit: PropTypes.bool.isRequired,
|
||||
canAddCollaborators: PropTypes.bool.isRequired,
|
||||
}
|
||||
|
||||
type SelectPrivilegeProps = {
|
||||
value: string
|
||||
handleChange: (item: Privilege | null | undefined) => void
|
||||
hasBeenDowngraded: boolean
|
||||
canAddCollaborators: boolean
|
||||
}
|
||||
|
||||
function SelectPrivilege({
|
||||
value,
|
||||
handleChange,
|
||||
hasBeenDowngraded,
|
||||
canAddCollaborators,
|
||||
}: SelectPrivilegeProps) {
|
||||
const { t } = useTranslation()
|
||||
const { features } = useProjectContext()
|
||||
|
||||
const privileges = useMemo(
|
||||
(): Privilege[] =>
|
||||
getMeta('ol-isReviewerRoleEnabled')
|
||||
? [
|
||||
{ key: 'owner', label: t('make_owner') },
|
||||
{ key: 'readAndWrite', label: t('editor') },
|
||||
{ key: 'review', label: t('reviewer') },
|
||||
{ key: 'readOnly', label: t('viewer') },
|
||||
{ key: 'removeAccess', label: t('remove_access') },
|
||||
]
|
||||
: [
|
||||
{ key: 'owner', label: t('make_owner') },
|
||||
{ key: 'readAndWrite', label: t('editor') },
|
||||
{ key: 'readOnly', label: t('viewer') },
|
||||
{ key: 'removeAccess', label: t('remove_access') },
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const downgradedPseudoPrivilege: Privilege = {
|
||||
key: 'downgraded',
|
||||
label: t('select_access_level'),
|
||||
}
|
||||
|
||||
function getPrivilegeSubtitle(privilege: PermissionsOption) {
|
||||
if (!['readAndWrite', 'review'].includes(privilege)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (hasBeenDowngraded) {
|
||||
if (isSplitTestEnabled('reviewer-role')) {
|
||||
return t('limited_to_n_collaborators_per_project', {
|
||||
count: features.collaborators,
|
||||
})
|
||||
} else {
|
||||
return t('limited_to_n_editors', { count: features.collaborators })
|
||||
}
|
||||
} else if (
|
||||
!canAddCollaborators &&
|
||||
!['readAndWrite', 'review'].includes(value)
|
||||
) {
|
||||
if (isSplitTestEnabled('reviewer-role')) {
|
||||
return t('limited_to_n_collaborators_per_project', {
|
||||
count: features.collaborators,
|
||||
})
|
||||
} else {
|
||||
return t('limited_to_n_editors_per_project', {
|
||||
count: features.collaborators,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivilegeDisabled(privilege: PermissionsOption) {
|
||||
return (
|
||||
!canAddCollaborators &&
|
||||
['readAndWrite', 'review'].includes(privilege) &&
|
||||
(hasBeenDowngraded || !['readAndWrite', 'review'].includes(value))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
items={privileges}
|
||||
itemToKey={item => item.key}
|
||||
itemToString={item => (item ? item.label : '')}
|
||||
itemToSubtitle={item => (item ? getPrivilegeSubtitle(item.key) : '')}
|
||||
itemToDisabled={item => (item ? isPrivilegeDisabled(item.key) : false)}
|
||||
defaultItem={privileges.find(item => item.key === value)}
|
||||
selected={
|
||||
hasBeenDowngraded
|
||||
? downgradedPseudoPrivilege
|
||||
: privileges.find(item => item.key === value)
|
||||
}
|
||||
name="privileges"
|
||||
onSelectedItemChanged={handleChange}
|
||||
selectedIcon
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ChangePrivilegesActionsProps = {
|
||||
handleReset: React.ComponentProps<typeof OLButton>['onClick']
|
||||
}
|
||||
|
||||
function ChangePrivilegesActions({
|
||||
handleReset,
|
||||
}: ChangePrivilegesActionsProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { linkSharingEnforcementDate } from '../utils/link-sharing'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
|
||||
type EditorOverLimitModalContentProps = {
|
||||
handleHide: () => void
|
||||
}
|
||||
|
||||
export default function EditorOverLimitModalContent({
|
||||
handleHide,
|
||||
}: EditorOverLimitModalContentProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{t('do_you_need_edit_access')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<p>
|
||||
{t('this_project_has_more_than_max_collabs', {
|
||||
linkSharingDate: linkSharingEnforcementDate,
|
||||
})}
|
||||
</p>
|
||||
<p>{t('to_keep_edit_access')}</p>
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
href="/blog/changes-to-project-sharing"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => {
|
||||
sendMB('notification-click', {
|
||||
name: 'link-sharing-collaborator-limit',
|
||||
button: 'learn',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('learn_more')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
sendMB('notification-click', {
|
||||
name: 'link-sharing-collaborator-limit',
|
||||
button: 'ok',
|
||||
})
|
||||
handleHide()
|
||||
}}
|
||||
>
|
||||
{t('ok')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import EditorOverLimitModalContent from './editor-over-limit-modal-content'
|
||||
import customLocalStorage from '@/infrastructure/local-storage'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useEditorContext } from '@/shared/context/editor-context'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import OLModal from '@/features/ui/components/ol/ol-modal'
|
||||
|
||||
const EditorOverLimitModal = () => {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
const { isProjectOwner, permissionsLevel } = useEditorContext()
|
||||
const { members, features, _id: projectId } = useProjectContext()
|
||||
|
||||
const handleHide = () => {
|
||||
setShow(false)
|
||||
}
|
||||
|
||||
// show the over-limit warning if user
|
||||
// is editor on a project over
|
||||
// collaborator limit (once every 24 hours)
|
||||
useEffect(() => {
|
||||
const showModalCooldownHours = 24
|
||||
const hasExceededCollaboratorLimit = () => {
|
||||
if (isProjectOwner || !features || permissionsLevel === 'readOnly') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (features.collaborators === -1) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
members.filter(member => member.privileges === 'readAndWrite').length >
|
||||
(features.collaborators ?? 1)
|
||||
)
|
||||
}
|
||||
|
||||
if (hasExceededCollaboratorLimit()) {
|
||||
const localStorageKey = `last-shown-need-edit-modal.${projectId}`
|
||||
const lastShownNeedEditModalTime =
|
||||
customLocalStorage.getItem(localStorageKey)
|
||||
if (
|
||||
!lastShownNeedEditModalTime ||
|
||||
lastShownNeedEditModalTime + showModalCooldownHours * 60 * 60 * 1000 <
|
||||
Date.now()
|
||||
) {
|
||||
setShow(true)
|
||||
customLocalStorage.setItem(localStorageKey, Date.now())
|
||||
sendMB('notification-prompt', {
|
||||
name: 'link-sharing-collaborator-limit',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [features, isProjectOwner, members, permissionsLevel, projectId])
|
||||
|
||||
return show ? (
|
||||
<OLModal
|
||||
animation
|
||||
show={show}
|
||||
onHide={() => {
|
||||
sendMB('notification-dismiss', {
|
||||
name: 'link-sharing-collaborator-limit',
|
||||
})
|
||||
handleHide()
|
||||
}}
|
||||
id="editor-over-limit-modal"
|
||||
>
|
||||
<EditorOverLimitModalContent handleHide={handleHide} />
|
||||
</OLModal>
|
||||
) : null
|
||||
}
|
||||
|
||||
export default EditorOverLimitModal
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useCallback } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useShareProjectContext } from './share-project-modal'
|
||||
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'
|
||||
|
||||
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="btn-inline-link text-decoration-none"
|
||||
>
|
||||
<MaterialIcon type="clear" />
|
||||
</OLButton>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
RevokeInvite.propTypes = {
|
||||
invite: PropTypes.object.isRequired,
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useCallback, useState, useEffect } from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 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)}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<MaterialIcon type="help" className="align-middle" />
|
||||
</a>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function MemberPrivileges({ privileges }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
switch (privileges) {
|
||||
case 'readAndWrite':
|
||||
return t('editor')
|
||||
|
||||
case 'readOnly':
|
||||
return t('viewer')
|
||||
|
||||
case 'review':
|
||||
return t('reviewer')
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
MemberPrivileges.propTypes = {
|
||||
privileges: PropTypes.string.isRequired,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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'
|
||||
|
||||
export default function OwnerInfo() {
|
||||
const { t } = useTranslation()
|
||||
const { owner } = useProjectContext()
|
||||
|
||||
return (
|
||||
<OLRow className="project-member">
|
||||
<OLCol xs={8}>
|
||||
<div className="project-member-email-icon">
|
||||
<MaterialIcon type="person" />
|
||||
<div className="email-warning">{owner?.email}</div>
|
||||
</div>
|
||||
</OLCol>
|
||||
<OLCol xs={4} className="text-end">
|
||||
{t('owner')}
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
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 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 { 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 && (
|
||||
<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
|
||||
data-testid="collaborator-email-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>
|
||||
<ul
|
||||
{...getMenuProps()}
|
||||
className={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 {...getItemProps({ item, index })}>
|
||||
<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 handleClick = useCallback(
|
||||
event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
removeSelectedItem(selectedItem)
|
||||
focusInput()
|
||||
},
|
||||
[focusInput, removeSelectedItem, selectedItem]
|
||||
)
|
||||
|
||||
return (
|
||||
<Tag
|
||||
prepend={<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,
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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 { useEditorContext } from '@/shared/context/editor-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()
|
||||
const { isPendingEditor } = useEditorContext()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isPendingEditor && (
|
||||
<Notification
|
||||
isActionBelowContent
|
||||
type="info"
|
||||
title={t('youve_lost_collaboration_access')}
|
||||
content={
|
||||
<div>
|
||||
<p>{t('this_project_already_has_maximum_collaborators')}</p>
|
||||
<p>
|
||||
{t(
|
||||
'please_ask_the_project_owner_to_upgrade_more_collaborators'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<OLRow className="public-access-level public-access-level-notice">
|
||||
<OLCol className="text-center">
|
||||
<AccessLevel level={publicAccessLevel} />
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AccessLevelProps = {
|
||||
level: PublicAccessLevel | undefined
|
||||
}
|
||||
|
||||
function AccessLevel({ level }: AccessLevelProps) {
|
||||
const { t } = useTranslation()
|
||||
switch (level) {
|
||||
case 'private':
|
||||
return <span>{t('to_add_more_collaborators')}</span>
|
||||
|
||||
case 'tokenBased':
|
||||
return <span>{t('to_change_access_permissions')}</span>
|
||||
|
||||
default:
|
||||
return <span>''</span>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +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,
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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 !== 'readOnly'
|
||||
).length
|
||||
|
||||
return (
|
||||
members.filter(member => member.privileges !== 'readOnly').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 =>
|
||||
['readAndWrite', 'review'].includes(member.privileges)
|
||||
) &&
|
||||
members.some(member => member.pendingEditor || member.pendingReviewer)
|
||||
)
|
||||
}, [members])
|
||||
|
||||
const haveAnyEditorsBeenDowngraded = useMemo(() => {
|
||||
if (!isProjectOwner || !features) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (features.collaborators === -1) {
|
||||
return false
|
||||
}
|
||||
return members.some(
|
||||
member => member.pendingEditor || member.pendingReviewer
|
||||
)
|
||||
}, [features, isProjectOwner, members])
|
||||
|
||||
const hasExceededCollaboratorLimit = useMemo(() => {
|
||||
if (!isProjectOwner || !features) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (features.collaborators === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
members.filter(member => member.privileges !== 'readOnly').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.privileges === 'review'),
|
||||
...members.filter(member => member.pendingReviewer),
|
||||
...members.filter(
|
||||
member =>
|
||||
!member.pendingEditor &&
|
||||
!member.pendingReviewer &&
|
||||
!['readAndWrite', 'review'].includes(member.privileges)
|
||||
),
|
||||
]
|
||||
}, [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={Boolean(
|
||||
member.pendingEditor || member.pendingReviewer
|
||||
)}
|
||||
canAddCollaborators={canAddCollaborators}
|
||||
isReviewerOnFreeProject={
|
||||
member.privileges === 'review' && !features.trackChanges
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ViewMember key={member._id} member={member} />
|
||||
)
|
||||
)}
|
||||
|
||||
{invites.map(invite => (
|
||||
<Invite
|
||||
key={invite._id}
|
||||
invite={invite}
|
||||
isProjectOwner={isProjectOwner}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!getMeta('ol-ExposedSettings').recaptchaDisabled?.invite && (
|
||||
<RecaptchaConditions />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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 { 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">
|
||||
<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="me-auto">
|
||||
{inFlight && (
|
||||
<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')}</>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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 =>
|
||||
['readAndWrite', 'review'].includes(member.privileges)
|
||||
).length > (project.features.collaborators ?? 1) ||
|
||||
project.members.some(
|
||||
member => member.pendingEditor || member.pendingReviewer
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import PropTypes from 'prop-types'
|
||||
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 { 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="me-auto">
|
||||
{inflight && (
|
||||
<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,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import MemberPrivileges from './member-privileges'
|
||||
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'
|
||||
|
||||
export default function ViewMember({ member }) {
|
||||
return (
|
||||
<OLRow className="project-member">
|
||||
<OLCol xs={8}>
|
||||
<div className="project-member-email-icon">
|
||||
<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,
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import OLButton from '@/features/ui/components/ol/ol-button'
|
||||
import {
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/features/ui/components/ol/ol-modal'
|
||||
|
||||
type ViewOnlyAccessModalContentProps = {
|
||||
handleHide: () => void
|
||||
}
|
||||
|
||||
export default function ViewOnlyAccessModalContent({
|
||||
handleHide,
|
||||
}: ViewOnlyAccessModalContentProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{t('view_only_access')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
|
||||
<OLModalBody>
|
||||
<p>{t('this_project_already_has_maximum_collaborators')}</p>
|
||||
<p>{t('please_ask_the_project_owner_to_upgrade_more_collaborators')}</p>
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
href="/blog/changes-to-project-sharing"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={() => {
|
||||
sendMB('notification-click', {
|
||||
name: 'link-sharing-collaborator-limit',
|
||||
button: 'learn',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('learn_more')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
sendMB('notification-click', {
|
||||
name: 'link-sharing-collaborator-limit',
|
||||
button: 'ok',
|
||||
})
|
||||
handleHide()
|
||||
}}
|
||||
>
|
||||
{t('ok')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import ViewOnlyAccessModalContent from './view-only-access-modal-content'
|
||||
import customLocalStorage from '@/infrastructure/local-storage'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useEditorContext } from '@/shared/context/editor-context'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import OLModal from '@/features/ui/components/ol/ol-modal'
|
||||
|
||||
const ViewOnlyAccessModal = () => {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
const { isProjectOwner, isPendingEditor, permissionsLevel } =
|
||||
useEditorContext()
|
||||
const { members, features, _id: projectId } = useProjectContext()
|
||||
|
||||
const handleHide = () => {
|
||||
setShow(false)
|
||||
}
|
||||
|
||||
// show the view-only access modal if
|
||||
// is editor on a project over
|
||||
// collaborator limit (once every week)
|
||||
useEffect(() => {
|
||||
const showModalCooldownHours = 24 * 7 // 7 days
|
||||
const shouldShowToPendingEditor = () => {
|
||||
if (isProjectOwner || !features) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (features.collaborators === -1) {
|
||||
return false
|
||||
}
|
||||
return isPendingEditor
|
||||
}
|
||||
|
||||
if (shouldShowToPendingEditor()) {
|
||||
const localStorageKey = `last-shown-view-only-access-modal.${projectId}`
|
||||
const lastShownNeedEditModalTime =
|
||||
customLocalStorage.getItem(localStorageKey)
|
||||
if (
|
||||
!lastShownNeedEditModalTime ||
|
||||
lastShownNeedEditModalTime + showModalCooldownHours * 60 * 60 * 1000 <
|
||||
Date.now()
|
||||
) {
|
||||
setShow(true)
|
||||
customLocalStorage.setItem(localStorageKey, Date.now())
|
||||
sendMB('notification-prompt', {
|
||||
name: 'link-sharing-collaborator-limit',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [
|
||||
features,
|
||||
isProjectOwner,
|
||||
isPendingEditor,
|
||||
members,
|
||||
permissionsLevel,
|
||||
projectId,
|
||||
])
|
||||
|
||||
return show ? (
|
||||
<OLModal
|
||||
animation
|
||||
show={show}
|
||||
onHide={() => {
|
||||
sendMB('notification-dismiss', {
|
||||
name: 'link-sharing-collaborator-limit',
|
||||
})
|
||||
handleHide()
|
||||
}}
|
||||
id="editor-over-limit-modal"
|
||||
>
|
||||
<ViewOnlyAccessModalContent handleHide={handleHide} />
|
||||
</OLModal>
|
||||
) : null
|
||||
}
|
||||
|
||||
export default ViewOnlyAccessModal
|
||||
Reference in New Issue
Block a user