first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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,
}

View File

@@ -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>
)
}

View File

@@ -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')}
&nbsp;
<OLButton
variant="link"
className="btn-inline-link"
onClick={handleReset}
>
{t('change_or_cancel-cancel')}
</OLButton>
</div>
</div>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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

View File

@@ -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')}
.&nbsp;
{isProjectOwner && <ResendInvite invite={invite} />}
</div>
</OLCol>
<OLCol xs={3} className="text-end">
<MemberPrivileges privileges={invite.privileges} />
</OLCol>
{isProjectOwner && (
<OLCol xs={1} className="text-center">
<RevokeInvite invite={invite} />
</OLCol>
)}
</OLRow>
)
}
Invite.propTypes = {
invite: PropTypes.object.isRequired,
isProjectOwner: PropTypes.bool.isRequired,
}
function ResendInvite({ invite }) {
const { t } = useTranslation()
const { monitorRequest, setError, inFlight } = useShareProjectContext()
const { _id: projectId } = useProjectContext()
// const buttonRef = useRef(null)
//
const handleClick = useCallback(
() =>
monitorRequest(() => resendInvite(projectId, invite))
.catch(error => {
if (error?.response?.status === 404) {
setError('invite_expired')
}
if (error?.response?.status === 429) {
setError('invite_resend_limit_hit')
}
})
.finally(() => {
// NOTE: disabled as react-bootstrap v0.33.1 isn't forwarding the ref to the `button`
// if (buttonRef.current) {
// buttonRef.current.blur()
// }
document.activeElement.blur()
}),
[invite, monitorRequest, projectId, setError]
)
return (
<OLButton
variant="link"
className="btn-inline-link"
onClick={handleClick}
disabled={inFlight}
// ref={buttonRef}
>
{t('resend')}
</OLButton>
)
}
ResendInvite.propTypes = {
invite: PropTypes.object.isRequired,
}
function RevokeInvite({ invite }) {
const { t } = useTranslation()
const { updateProject, monitorRequest } = useShareProjectContext()
const { _id: projectId, invites, members } = useProjectContext()
function handleClick(event) {
event.preventDefault()
monitorRequest(() => revokeInvite(projectId, invite)).then(() => {
const updatedInvites = invites.filter(existing => existing !== invite)
updateProject({
invites: updatedInvites,
})
sendMB('collaborator-invite-revoked', {
project_id: projectId,
current_invites_amount: updatedInvites.length,
current_collaborators_amount: members.length,
})
})
}
return (
<OLTooltip
id="revoke-invite"
description={t('revoke_invite')}
overlayProps={{ placement: 'bottom' }}
>
<OLButton
variant="link"
onClick={handleClick}
aria-label={t('revoke')}
className="btn-inline-link text-decoration-none"
>
<MaterialIcon type="clear" />
</OLButton>
</OLTooltip>
)
}
RevokeInvite.propTypes = {
invite: PropTypes.object.isRequired,
}

View File

@@ -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>&nbsp;&nbsp;</span>
<OLButton
variant="link"
className="btn-inline-link"
onClick={() => {
setAccessLevel('tokenBased')
eventTracking.sendMB('link-sharing-click', { projectId })
setShowLinks(true)
}}
disabled={inflight}
>
{t('turn_on_link_sharing')}
</OLButton>
<span>&nbsp;&nbsp;</span>
<LinkSharingInfo />
</OLCol>
</OLRow>
)
}
PrivateSharing.propTypes = {
setAccessLevel: PropTypes.func.isRequired,
inflight: PropTypes.bool,
projectId: PropTypes.string,
setShowLinks: PropTypes.func.isRequired,
}
function TokenBasedSharing({
setAccessLevel,
inflight,
setShowLinks,
showLinks,
}) {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const [tokens, setTokens] = useState(null)
const { signal } = useAbortController()
useEffect(() => {
getJSON(`/project/${projectId}/tokens`, { signal })
.then(data => setTokens(data))
.catch(debugConsole.error)
}, [projectId, signal])
return (
<OLRow className="public-access-level">
<OLCol xs={12} className="text-center">
<strong>{t('link_sharing_is_on')}</strong>
<span>&nbsp;&nbsp;</span>
<OLButton
variant="link"
className="btn-inline-link"
onClick={() => setAccessLevel('private')}
disabled={inflight}
>
{t('turn_off_link_sharing')}
</OLButton>
<span>&nbsp;&nbsp;</span>
<LinkSharingInfo />
<OLButton
variant="link"
className="btn-chevron align-middle"
onClick={() => setShowLinks(!showLinks)}
>
<MaterialIcon
type={showLinks ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
/>
</OLButton>
</OLCol>
{showLinks && (
<OLCol xs={12} className="access-token-display-area">
<div className="access-token-wrapper">
<strong>{t('anyone_with_link_can_edit')}</strong>
<AccessToken
token={tokens?.readAndWrite}
tokenHashPrefix={tokens?.readAndWriteHashPrefix}
path="/"
tooltipId="tooltip-copy-link-rw"
/>
</div>
<div className="access-token-wrapper">
<strong>{t('anyone_with_link_can_view')}</strong>
<AccessToken
token={tokens?.readOnly}
tokenHashPrefix={tokens?.readOnlyHashPrefix}
path="/read/"
tooltipId="tooltip-copy-link-ro"
/>
</div>
</OLCol>
)}
</OLRow>
)
}
TokenBasedSharing.propTypes = {
setAccessLevel: PropTypes.func.isRequired,
inflight: PropTypes.bool,
setShowLinks: PropTypes.func.isRequired,
showLinks: PropTypes.bool,
}
function LegacySharing({ accessLevel, setAccessLevel, inflight }) {
const { t } = useTranslation()
return (
<OLRow className="public-access-level">
<OLCol xs={12} className="text-center">
<strong>
{accessLevel === 'readAndWrite' && t('this_project_is_public')}
{accessLevel === 'readOnly' && t('this_project_is_public_read_only')}
</strong>
<span>&nbsp;&nbsp;</span>
<OLButton
variant="link"
className="btn-inline-link"
onClick={() => setAccessLevel('private')}
disabled={inflight}
>
{t('make_private')}
</OLButton>
<span>&nbsp;&nbsp;</span>
<LinkSharingInfo />
</OLCol>
</OLRow>
)
}
LegacySharing.propTypes = {
accessLevel: PropTypes.string.isRequired,
setAccessLevel: PropTypes.func.isRequired,
inflight: PropTypes.bool,
}
export function ReadOnlyTokenLink() {
const { t } = useTranslation()
const { _id: projectId } = useProjectContext()
const [tokens, setTokens] = useState(null)
const { signal } = useAbortController()
useEffect(() => {
getJSON(`/project/${projectId}/tokens`, { signal })
.then(data => setTokens(data))
.catch(debugConsole.error)
}, [projectId, signal])
return (
<OLRow className="public-access-level">
<OLCol className="access-token-display-area">
<div className="access-token-wrapper">
<strong>{t('anyone_with_link_can_view')}</strong>
<AccessToken
token={tokens?.readOnly}
tokenHashPrefix={tokens?.readOnlyHashPrefix}
path="/read/"
tooltipId="tooltip-copy-link-ro"
/>
</div>
</OLCol>
</OLRow>
)
}
function AccessToken({ token, tokenHashPrefix, path, tooltipId }) {
const { t } = useTranslation()
const { isAdmin } = useUserContext()
if (!token) {
return (
<pre className="access-token">
<span>{t('loading')}</span>
</pre>
)
}
let origin = window.location.origin
if (isAdmin) {
origin = getMeta('ol-ExposedSettings').siteUrl
}
const link = `${origin}${path}${token}${
tokenHashPrefix ? `#${tokenHashPrefix}` : ''
}`
return (
<div className="access-token">
<code>{link}</code>
<CopyToClipboard content={link} tooltipId={tooltipId} />
</div>
)
}
AccessToken.propTypes = {
token: PropTypes.string,
tokenHashPrefix: PropTypes.string,
tooltipId: PropTypes.string.isRequired,
path: PropTypes.string.isRequired,
}
function LinkSharingInfo() {
const { t } = useTranslation()
return (
<OLTooltip
id="link-sharing-info"
description={t('learn_more_about_link_sharing')}
>
<a
href="/learn/how-to/What_is_Link_Sharing%3F"
target="_blank"
rel="noopener"
>
<MaterialIcon type="help" className="align-middle" />
</a>
</OLTooltip>
)
}

View File

@@ -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,
}

View File

@@ -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>
)
}

View File

@@ -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')}
&nbsp;
</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,
}

View File

@@ -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>
}
}

View File

@@ -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,
}

View File

@@ -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 />
)}
</>
)
}

View File

@@ -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')}</>
}
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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>
</>
)
}

View File

@@ -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