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
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getJSON } from '../../../infrastructure/fetch-json'
|
||||
import useAbortController from '../../../shared/hooks/use-abort-controller'
|
||||
|
||||
export function useUserContacts() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const { signal } = useAbortController()
|
||||
|
||||
useEffect(() => {
|
||||
getJSON('/user/contacts', { signal })
|
||||
.then(data => {
|
||||
setData(data.contacts.map(buildContact))
|
||||
})
|
||||
.catch(error => setError(error))
|
||||
.finally(() => setLoading(false))
|
||||
}, [signal])
|
||||
|
||||
return { loading, data, error }
|
||||
}
|
||||
|
||||
function buildContact(contact) {
|
||||
const [emailPrefix] = contact.email.split('@')
|
||||
|
||||
// the name is not just the default "email prefix as first name"
|
||||
const hasName = contact.last_name || contact.first_name !== emailPrefix
|
||||
|
||||
const name = hasName
|
||||
? [contact.first_name, contact.last_name].filter(Boolean).join(' ')
|
||||
: ''
|
||||
|
||||
return {
|
||||
...contact,
|
||||
name,
|
||||
display: name ? `${name} <${contact.email}>` : contact.email,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg width="52" height="52" viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M48.0826 35.6938C48.3169 34.853 48.4106 33.9731 48.3584 33.091C48.1744 29.9799 46.225 27.385 43.5442 26.2212C43.767 25.8 43.952 25.2937 44.072 24.6746C44.2255 23.8823 44.2512 22.9627 44.1455 22.2222C44.0954 21.8682 44.043 21.5361 43.988 21.2224C43.3885 17.7389 41.2497 14.7143 38.1578 13.003C36.1692 11.9002 33.0845 11.0518 32.7741 9.8061C32.7604 9.75955 32.7501 9.70916 32.7364 9.66261C32.7336 9.55349 32.7342 9.44053 32.7278 9.33162C32.5169 5.7668 30.1751 2.94368 27.0309 2.07326C26.3253 1.84544 25.5578 1.74149 24.7265 1.79067C24.6866 1.79303 24.643 1.7956 24.6031 1.79797C24.5632 1.80033 24.5196 1.80291 24.4797 1.80527C23.6484 1.85445 22.8985 2.04817 22.2247 2.35761C19.2053 3.59632 17.2125 6.67223 17.4234 10.2371C17.4301 10.3496 17.4438 10.4581 17.4539 10.5668C17.4458 10.6146 17.4379 10.6661 17.4334 10.7137C17.272 11.9873 14.3092 13.1972 12.4642 14.523C9.59562 16.5871 7.82842 19.8427 7.64384 23.3726C7.62622 23.6906 7.61331 24.0265 7.60532 24.384C7.584 25.132 7.72162 26.0419 7.96745 26.8106C8.15962 27.4112 8.40322 27.8959 8.6739 28.2842C6.14911 29.756 4.51928 32.5627 4.70333 35.6737C4.75552 36.5559 4.9559 37.4183 5.28417 38.2258C3.75414 40.0103 3.06714 42.3751 3.23766 45.2574L3.34956 47.1487C3.48679 49.4684 5.47841 51.2376 7.79802 51.1004L47.1079 48.7747C49.4275 48.6375 51.1966 46.6458 51.0594 44.3261L50.9475 42.4348C50.777 39.5524 49.8159 37.2851 48.0862 35.6935L48.0826 35.6938Z" fill="#DADBEB"/>
|
||||
<path d="M20.1336 22.7658L32.5824 22.0293C33.0054 22.0043 33.3254 21.6379 33.3 21.2078L33.1493 18.6601C33.0373 16.7685 32.1918 15.0008 30.8007 13.7408C30.3105 13.2979 29.7637 12.9316 29.1822 12.6478C29.7958 11.7118 30.1193 10.5515 30.0457 9.30865C29.8752 6.42566 27.6432 4.20594 24.8079 4.37368C21.9726 4.54142 19.8062 7.0177 19.977 9.90433C20.0518 11.1691 20.5613 12.302 21.3496 13.1624C20.8374 13.5036 20.3687 13.9117 19.9542 14.3825C18.7178 15.7979 18.4235 17.6331 18.5354 19.5247L18.6478 21.4236C18.6943 22.2109 19.3594 22.8116 20.1336 22.7658ZM24.9729 7.22408C26.2633 7.14774 27.1615 8.16259 27.2391 9.47469C27.3167 10.7868 26.5409 11.9007 25.2541 11.9768C23.9673 12.0529 22.8541 11.0508 22.7764 9.73871C22.6988 8.42661 23.6825 7.30042 24.9729 7.22408ZM21.2953 18.6299C21.247 17.8135 21.4682 16.9921 21.9851 16.3654C22.6194 15.5963 23.4392 15.226 24.4101 14.9783C24.7327 14.8971 25.0643 14.8482 25.3976 14.8285L26.2579 14.7776C27.6428 14.7176 28.1017 15.1184 28.9378 15.8736C29.7738 16.6287 30.2827 17.6922 30.3498 18.8257C30.3653 19.0882 30.1699 19.3119 29.9118 19.3271L21.8396 19.8047C21.5816 19.82 21.3612 19.6209 21.3456 19.3584L21.3027 18.6331L21.2953 18.6299Z" fill="black"/>
|
||||
<path d="M45.9437 37.4303C45.4399 36.9882 44.8776 36.6228 44.2796 36.34C44.9128 35.4028 45.2478 34.2419 45.1742 32.999C45.0037 30.116 42.7101 27.8999 39.7914 28.0726C36.8727 28.2453 34.6386 30.7256 34.8094 33.6122C34.8842 34.8769 35.4066 36.0091 36.2165 36.8683C35.6887 37.2104 35.2056 37.6193 34.7781 38.0908C33.503 39.5086 33.1969 41.3444 33.3089 43.2361L33.4212 45.135C33.4678 45.9222 34.1512 46.5219 34.9483 46.4747L47.7632 45.7165C48.1986 45.6908 48.5287 45.3238 48.5032 44.8937L48.3525 42.3461C48.2406 40.4544 47.3733 38.688 45.9437 37.4303ZM39.9564 30.923C41.2847 30.8444 42.2074 31.8578 42.2851 33.1699C42.3627 34.482 41.5622 35.5974 40.2375 35.6758C38.9129 35.7541 37.7688 34.7538 37.6912 33.4417C37.6135 32.1296 38.628 31.0016 39.9564 30.923ZM45.0154 43.019L36.7058 43.5106C36.4401 43.5263 36.2136 43.3276 36.198 43.0651L36.1551 42.3398C36.1068 41.5234 36.3359 40.7016 36.869 40.0738C37.5233 39.3036 38.3677 38.9318 39.3675 38.6825C39.6997 38.6007 40.0411 38.5512 40.3842 38.5309L41.2698 38.4785C42.6954 38.4161 43.1671 38.8161 44.0263 39.5699C44.8855 40.3238 45.4076 41.3865 45.4746 42.52C45.4901 42.7824 45.2886 43.0065 45.023 43.0222L45.0154 43.019Z" fill="black"/>
|
||||
<path d="M17.2014 37.9421C17.8347 37.0049 18.1696 35.8439 18.0961 34.6011C17.9255 31.7181 15.632 29.502 12.7133 29.6747C9.79461 29.8473 7.56045 32.3276 7.73123 35.2143C7.80606 36.479 8.32848 37.6112 9.13838 38.4703C8.61061 38.8124 8.12752 39.2214 7.69997 39.6929C6.42492 41.1106 6.11882 42.9465 6.23074 44.8381L6.34308 46.737C6.38966 47.5243 7.07312 48.1239 7.87013 48.0768L20.6851 47.3186C21.1205 47.2928 21.4506 46.9259 21.4251 46.4958L21.2744 43.9481C21.1625 42.0565 20.2952 40.29 18.8656 39.0323C18.3617 38.5903 17.7995 38.2249 17.2014 37.9421ZM12.8785 32.5287C14.2068 32.4501 15.1295 33.4635 15.2071 34.7756C15.2848 36.0877 14.4843 37.2031 13.1596 37.2814C11.835 37.3598 10.6909 36.3595 10.6132 35.0474C10.5356 33.7353 11.5501 32.6073 12.8785 32.5287ZM17.9375 44.6247L9.62785 45.1163C9.36217 45.132 9.13566 44.9333 9.12013 44.6708L9.07722 43.9455C9.02892 43.1291 9.258 42.3072 9.7911 41.6795C10.4454 40.9093 11.2898 40.5375 12.2896 40.2882C12.6217 40.2063 12.9632 40.1569 13.3063 40.1366L14.1919 40.0842C15.6175 40.0218 16.0892 40.4218 16.9484 41.1756C17.8076 41.9294 18.3296 42.9922 18.3967 44.1257C18.4122 44.3881 18.2107 44.6121 17.9451 44.6279L17.9375 44.6247Z" fill="black"/>
|
||||
<path d="M38.8187 24.1385C38.8691 24.9902 39.6853 25.6207 40.5685 25.4176C41.2579 25.2583 41.7414 24.5725 41.6495 23.8812C40.9588 18.6031 40.1158 17.396 38.0561 15.0003C37.6691 14.5491 37.0412 14.3312 36.4691 14.5051C35.4538 14.813 35.1324 16.0244 36.3531 17.4031C38.0806 19.4127 38.651 21.304 38.8187 24.1385Z" fill="black"/>
|
||||
<path d="M31.2327 36.6182C30.1581 37.0669 29.7409 37.2428 28.9992 37.4162C27.2134 37.8314 25.3481 37.7474 23.6016 37.1993C23.3002 37.1055 23.006 37.0114 22.716 36.9278C21.9798 36.7122 21.2068 37.1394 20.9944 37.8862C20.782 38.633 21.2093 39.4103 21.9455 39.6259C23.6861 40.4947 24.7654 40.6072 26.5517 40.5016C28.338 40.3959 29.5703 40.323 32.1305 39.0234C32.8362 38.7225 33.1686 37.8966 32.8699 37.1836C32.5711 36.4707 31.9419 36.3171 31.233 36.6218L31.2327 36.6182Z" fill="black"/>
|
||||
<path d="M11.7024 27.0321C12.8109 27.4147 13.487 26.4139 13.4414 25.6422C13.274 22.8126 13.6252 20.8918 15.1791 18.6918C15.9322 17.5753 16.0278 16.2933 15.26 16.0017C14.5758 15.5797 13.6528 15.7562 13.1996 16.4033C11.6437 19.3419 10.7905 19.6684 10.6425 24.4454C10.6196 25.2176 10.94 26.7689 11.7062 27.0319L11.7024 27.0321Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
deleteJSON,
|
||||
getJSON,
|
||||
postJSON,
|
||||
putJSON,
|
||||
} from '../../../infrastructure/fetch-json'
|
||||
import { executeV2Captcha } from './captcha'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
export function sendInvite(projectId, email, privileges) {
|
||||
return executeV2Captcha(
|
||||
getMeta('ol-ExposedSettings').recaptchaDisabled?.invite
|
||||
).then(grecaptchaResponse => {
|
||||
return postJSON(`/project/${projectId}/invite`, {
|
||||
body: {
|
||||
email, // TODO: normalisedEmail?
|
||||
privileges,
|
||||
'g-recaptcha-response': grecaptchaResponse,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function resendInvite(projectId, invite) {
|
||||
return postJSON(`/project/${projectId}/invite/${invite._id}/resend`)
|
||||
}
|
||||
|
||||
export function revokeInvite(projectId, invite) {
|
||||
return deleteJSON(`/project/${projectId}/invite/${invite._id}`)
|
||||
}
|
||||
|
||||
export function updateMember(projectId, member, data) {
|
||||
return putJSON(`/project/${projectId}/users/${member._id}`, {
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeMemberFromProject(projectId, member) {
|
||||
return deleteJSON(`/project/${projectId}/users/${member._id}`)
|
||||
}
|
||||
|
||||
export function transferProjectOwnership(projectId, member) {
|
||||
return postJSON(`/project/${projectId}/transfer-ownership`, {
|
||||
body: {
|
||||
user_id: member._id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setProjectAccessLevel(projectId, publicAccessLevel) {
|
||||
return postJSON(`/project/${projectId}/settings/admin`, {
|
||||
body: { publicAccessLevel },
|
||||
})
|
||||
}
|
||||
|
||||
export function listProjectMembers(projectId) {
|
||||
return getJSON(`/project/${projectId}/members`)
|
||||
}
|
||||
|
||||
export function listProjectInvites(projectId) {
|
||||
return getJSON(`/project/${projectId}/invites`)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
let _recaptchaId
|
||||
let _recaptchaResolve
|
||||
export function executeV2Captcha(disabled = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (disabled || !window.grecaptcha) {
|
||||
return resolve()
|
||||
}
|
||||
|
||||
try {
|
||||
if (!_recaptchaId) {
|
||||
_recaptchaId = window.grecaptcha.render('recaptcha', {
|
||||
callback: token => {
|
||||
if (_recaptchaResolve) {
|
||||
_recaptchaResolve(token)
|
||||
_recaptchaResolve = undefined
|
||||
}
|
||||
window.grecaptcha.reset()
|
||||
},
|
||||
})
|
||||
}
|
||||
_recaptchaResolve = resolve
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { formatDate } from '@/utils/dates'
|
||||
|
||||
export const linkSharingEnforcementDate = formatDate(
|
||||
new Date(2024, 7, 26),
|
||||
'MMMM Do'
|
||||
) // August 26th 2024
|
||||
Reference in New Issue
Block a user