Merge pull request #18861 from overleaf/rh-editor-limit-exceeded

[web]: Handle exceeded editor limit in share modal

GitOrigin-RevId: 23a15805ca98327ae4a7fc731bbca3982c90bad5
This commit is contained in:
roo hutton 2024-06-25 07:08:24 +01:00 committed by Copybot
parent 04432478e1
commit 64d9792fe3
32 changed files with 2135 additions and 70 deletions

View file

@ -578,6 +578,12 @@ const _ProjectController = {
? 'project/ide-react-detached'
: 'project/ide-react'
const assignLink = await SplitTestHandler.promises.getAssignmentForUser(
project.owner_ref,
'link-sharing-warning'
)
const linkSharingWarning = assignLink.variant === 'active'
res.render(template, {
title: project.name,
priority_title: true,
@ -651,6 +657,7 @@ const _ProjectController = {
optionalPersonalAccessToken,
hasTrackChangesFeature: Features.hasFeature('track-changes'),
projectTags,
linkSharingWarning,
})
timer.done()
} catch (err) {

View file

@ -36,6 +36,7 @@ meta(name="ol-optionalPersonalAccessToken", data-type="boolean" content=optional
meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature)
meta(name="ol-inactiveTutorials", data-type="json" content=user.inactiveTutorials)
meta(name="ol-projectTags" data-type="json" content=projectTags)
meta(name="ol-linkSharingWarning" data-type="boolean" content=linkSharingWarning)
meta(name="ol-loadingText", data-type="string" content=translate("loading"))
meta(name="ol-translationLoadErrorMessage", data-type="string" content=translate("could_not_load_translations"))

View file

@ -51,6 +51,7 @@
"add_more_members": "",
"add_new_email": "",
"add_or_remove_project_from_tag": "",
"add_people": "",
"add_role_and_department": "",
"add_to_tag": "",
"add_your_comment_here": "",
@ -359,8 +360,10 @@
"edit_tag": "",
"editing": "",
"editing_captions": "",
"editor": "",
"editor_and_pdf": "",
"editor_disconected_click_to_reconnect": "",
"editor_limit_exceeded_in_this_project": "",
"editor_only_hide_pdf": "",
"editor_theme": "",
"educational_discount_for_groups_of_x_or_more": "",
@ -647,6 +650,7 @@
"invalid_password_contains_email": "",
"invalid_password_too_similar": "",
"invalid_request": "",
"invite": "",
"invite_more_collabs": "",
"invite_not_accepted": "",
"invited_to_group": "",
@ -703,6 +707,7 @@
"library": "",
"license_for_educational_purposes": "",
"limited_offer": "",
"limited_to_n_editors_per_project": "",
"line_height": "",
"line_width_is_the_width_of_the_line_in_the_current_environment": "",
"link": "",
@ -712,6 +717,7 @@
"link_institutional_email_get_started": "",
"link_sharing": "",
"link_sharing_is_off": "",
"link_sharing_is_off_short": "",
"link_sharing_is_on": "",
"link_to_github": "",
"link_to_github_description": "",
@ -747,6 +753,7 @@
"main_file_not_found": "",
"make_a_copy": "",
"make_email_primary_description": "",
"make_owner": "",
"make_primary": "",
"make_private": "",
"manage_beta_program_membership": "",
@ -1014,6 +1021,7 @@
"react_history_tutorial_title": "",
"reactivate_subscription": "",
"read_lines_from_path": "",
"read_more": "",
"read_more_about_free_compile_timeouts_servers": "",
"read_only": "",
"read_only_token": "",
@ -1053,6 +1061,7 @@
"remind_before_trial_ends": "",
"remote_service_error": "",
"remove": "",
"remove_access": "",
"remove_collaborator": "",
"remove_from_group": "",
"remove_link": "",
@ -1522,6 +1531,7 @@
"upgrade_cc_btn": "",
"upgrade_for_12x_more_compile_time": "",
"upgrade_now": "",
"upgrade_to_add_more_editors": "",
"upgrade_to_get_feature": "",
"upgrade_to_track_changes": "",
"upload": "",
@ -1569,6 +1579,7 @@
"view_options": "",
"view_pdf": "",
"view_your_invoices": "",
"viewer": "",
"viewing_x": "",
"visual_editor": "",
"visual_editor_is_only_available_for_tex_files": "",
@ -1590,6 +1601,7 @@
"what_should_we_call_you": "",
"when_you_tick_the_include_caption_box": "",
"wide": "",
"will_lose_edit_access_on_date": "",
"with_premium_subscription_you_also_get": "",
"word_count": "",
"work_offline": "",
@ -1627,6 +1639,7 @@
"you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
"you_can_now_enable_sso": "",
"you_can_now_log_in_sso": "",
"you_can_only_add_n_people_to_edit_a_project": "",
"you_can_request_a_maximum_of_limit_fixes_per_day": "",
"you_cant_add_or_change_password_due_to_sso": "",
"you_cant_join_this_group_subscription": "",
@ -1657,6 +1670,7 @@
"your_password_was_detected": "",
"your_plan": "",
"your_plan_is_changing_at_term_end": "",
"your_plan_is_limited_to_n_editors": "",
"your_project_exceeded_compile_timeout_limit_on_free_plan": "",
"your_project_near_compile_timeout_limit": "",
"your_projects": "",

View file

@ -3,7 +3,9 @@ import { useOnlineUsersContext } from '@/features/ide-react/context/online-users
import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
import * as eventTracking from '@/infrastructure/event-tracking'
import EditorNavigationToolbarRoot from '@/features/editor-navigation-toolbar/components/editor-navigation-toolbar-root'
import NewShareProjectModal from '@/features/share-project-modal/components/restricted-link-sharing/share-project-modal'
import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal'
import getMeta from '@/utils/meta'
function EditorNavigationToolbar() {
const [showShareModal, setShowShareModal] = useState(false)
@ -19,6 +21,8 @@ function EditorNavigationToolbar() {
setShowShareModal(false)
}, [])
const showNewShareModal = getMeta('ol-linkSharingWarning')
return (
<>
<EditorNavigationToolbarRoot
@ -26,10 +30,17 @@ function EditorNavigationToolbar() {
openDoc={openDoc}
openShareProjectModal={handleOpenShareModal}
/>
<ShareProjectModal
show={showShareModal}
handleHide={handleHideShareModal}
/>
{showNewShareModal ? (
<NewShareProjectModal
show={showShareModal}
handleHide={handleHideShareModal}
/>
) : (
<ShareProjectModal
show={showShareModal}
handleHide={handleHideShareModal}
/>
)}
</>
)
}

View file

@ -0,0 +1,36 @@
import { useTranslation } from 'react-i18next'
import { Button } from 'react-bootstrap'
import Notification from '@/shared/components/notification'
import { upgradePlan } from '../../../../main/account-upgrade'
export default function AddCollaboratorsUpgrade() {
const { t } = useTranslation()
return (
<div className="add-collaborators-upgrade">
<Notification
type="warning"
title={t('editor_limit_exceeded_in_this_project')}
content={<p>{t('your_plan_is_limited_to_n_editors')}</p>}
action={
<div className="upgrade-actions">
<Button
bsSize="sm"
className="btn-secondary"
onClick={() => upgradePlan('project-sharing')}
>
{t('upgrade')}
</Button>
<a
href="https://www.overleaf.com/blog/changes-to-project-sharing"
target="_blank"
rel="noreferrer"
>
{t('read_more')}
</a>
</div>
}
/>
</div>
)
}

View file

@ -0,0 +1,174 @@
import { useState, useMemo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Col, Form, Button } from 'react-bootstrap'
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'
export default function AddCollaborators({ readOnly }) {
const [privileges, setPrivileges] = useState('readAndWrite')
const isMounted = useIsMounted()
const { data: contacts } = useUserContacts()
const { t } = useTranslation()
const { updateProject, setInFlight, setError } = useShareProjectContext()
const { _id: projectId, members, invites } = useProjectContext()
const currentMemberEmails = useMemo(
() => (members || []).map(member => member.email).sort(),
[members]
)
const nonMemberContacts = useMemo(() => {
if (!contacts) {
return null
}
return contacts.filter(
contact => !currentMemberEmails.includes(contact.email)
)
}, [contacts, currentMemberEmails])
const multipleSelectionProps = useMultipleSelection({
initialActiveIndex: 0,
initialSelectedItems: [],
})
const { reset, selectedItems } = multipleSelectionProps
const handleSubmit = useCallback(async () => {
if (!selectedItems.length) {
return
}
// reset the selected items
reset()
setError(undefined)
setInFlight(true)
for (const contact of selectedItems) {
// unmounting means can't add any more collaborators
if (!isMounted.current) {
break
}
const email = contact.type === 'user' ? contact.email : contact.display
const normalisedEmail = email.toLowerCase()
if (currentMemberEmails.includes(normalisedEmail)) {
continue
}
let data
try {
const invite = (invites || []).find(
invite => invite.email === normalisedEmail
)
if (invite) {
data = await resendInvite(projectId, invite)
} else {
data = await sendInvite(projectId, email, privileges)
}
sendMB('collaborator-invited', {
project_id: projectId,
// invitation is only populated on successful invite, meaning that for paywall and other cases this will be null
successful_invite: !!data.invite,
users_updated: !!(data.users || data.user),
current_collaborators_amount: members.length,
current_invites_amount: invites.length,
})
} catch (error) {
setInFlight(false)
setError(
error.data?.errorReason ||
(error.response?.status === 429
? 'too_many_requests'
: 'generic_something_went_wrong')
)
break
}
if (data.error) {
setError(data.error)
setInFlight(false)
} else if (data.invite) {
updateProject({
invites: invites.concat(data.invite),
})
} else if (data.users) {
updateProject({
members: members.concat(data.users),
})
} else if (data.user) {
updateProject({
members: members.concat(data.user),
})
}
// wait for a short time, so canAddCollaborators has time to update with new collaborator information
await new Promise(resolve => setTimeout(resolve, 100))
}
setInFlight(false)
}, [
currentMemberEmails,
invites,
isMounted,
members,
privileges,
projectId,
reset,
selectedItems,
setError,
setInFlight,
updateProject,
])
return (
<Form className="add-collabs">
<Col xs={10}>
<SelectCollaborators
loading={!nonMemberContacts}
options={nonMemberContacts || []}
placeholder="Email, comma separated"
multipleSelectionProps={multipleSelectionProps}
privileges={privileges}
setPrivileges={setPrivileges}
readOnly={readOnly}
/>
</Col>
<Col xs={2}>
<div>
<span>&nbsp;&nbsp;&nbsp;&nbsp;</span>
<ClickableElementEnhancer
as={Button}
onClick={handleSubmit}
bsStyle="primary"
>
{t('invite')}
</ClickableElementEnhancer>
</div>
</Col>
</Form>
)
}
AddCollaborators.propTypes = {
readOnly: PropTypes.bool,
}

View file

@ -0,0 +1,28 @@
import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import Notification from '@/shared/components/notification'
import { upgradePlan } from '@/main/account-upgrade'
export default function CollaboratorsLimitUpgrade() {
const { t } = useTranslation()
return (
<div>
<Notification
type="info"
customIcon={<div />}
title={t('upgrade_to_add_more_editors')}
content={<p>{t('you_can_only_add_n_people_to_edit_a_project')}</p>}
action={
<Button
bsSize="sm"
className="btn-secondary"
onClick={() => upgradePlan('project-sharing')}
>
{t('upgrade')}
</Button>
}
/>
</div>
)
}

View file

@ -0,0 +1,242 @@
import { useState, useEffect, useMemo, MouseEventHandler } from 'react'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { useShareProjectContext } from './share-project-modal'
import TransferOwnershipModal from './transfer-ownership-modal'
import { removeMemberFromProject, updateMember } from '../../utils/api'
import { Button, Col, Form, FormGroup } from 'react-bootstrap'
import Icon from '@/shared/components/icon'
import { useProjectContext } from '@/shared/context/project-context'
import { sendMB } from '@/infrastructure/event-tracking'
import { Select } from '@/shared/components/select'
import type { ProjectContextMember } from '@/shared/context/types/project-context'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
type PermissionsOption = PermissionsLevel | 'removeAccess'
type EditMemberProps = {
member: ProjectContextMember
hasExceededCollaboratorLimit: boolean
canAddCollaborators: boolean
}
type Privilege = {
key: PermissionsOption
label: string
}
export default function EditMember({
member,
hasExceededCollaboratorLimit,
canAddCollaborators,
}: 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()
// Immediately commit this change if it's lower impact (eg. editor > viewer)
// but show a confirmation button for removing access
function handlePrivilegeChange(newPrivileges: PermissionsOption) {
setPrivileges(newPrivileges)
if (newPrivileges !== 'removeAccess') {
commitPrivilegeChange(newPrivileges)
} else {
setPrivilegeChangePending(true)
}
}
function shouldWarnMember() {
return hasExceededCollaboratorLimit && privileges === 'readAndWrite'
}
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 === '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)
}}
/>
)
}
return (
<Form
horizontal
id="share-project-form"
onSubmit={e => {
e.preventDefault()
commitPrivilegeChange(privileges)
}}
>
<FormGroup className="project-member">
<Col xs={7}>
<div className="project-member-email-icon">
<Icon type={shouldWarnMember() ? 'warning' : 'user'} fw />
<div className="email-warning">
{member.email}
{shouldWarnMember() && (
<div className="subtitle">
{t('will_lose_edit_access_on_date', {
date: '[date]',
})}
</div>
)}
</div>
</div>
</Col>
<Col xs={2}>
{privileges !== member.privileges && privilegeChangePending && (
<ChangePrivilegesActions
handleReset={() => setPrivileges(member.privileges)}
/>
)}
</Col>
<Col xs={3}>
<SelectPrivilege
value={privileges}
handleChange={value => {
value && handlePrivilegeChange(value.key)
}}
canAddCollaborators={canAddCollaborators}
/>
</Col>
</FormGroup>
</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
canAddCollaborators: boolean
}
function SelectPrivilege({
value,
handleChange,
canAddCollaborators,
}: SelectPrivilegeProps) {
const { t } = useTranslation()
const privileges = useMemo(
(): Privilege[] => [
{ key: 'owner', label: t('make_owner') },
{ key: 'readAndWrite', label: t('editor') },
{ key: 'readOnly', label: t('viewer') },
{ key: 'removeAccess', label: t('remove_access') },
],
[t]
)
function getPrivilegeSubtitle(privilege: PermissionsOption) {
return !canAddCollaborators &&
privilege === 'readAndWrite' &&
value !== 'readAndWrite'
? t('limited_to_n_editors_per_project')
: ''
}
return (
<Select
items={privileges}
itemToKey={item => item.key}
itemToString={item => (item ? item.label : '')}
itemToSubtitle={item => (item ? getPrivilegeSubtitle(item.key) : '')}
itemToDisabled={item =>
item ? getPrivilegeSubtitle(item.key) !== '' : false
}
defaultItem={privileges.find(item => item.key === value)}
selected={privileges.find(item => item.key === value)}
name="privileges"
onSelectedItemChanged={handleChange}
selectedIcon
/>
)
}
type ChangePrivilegesActionsProps = {
handleReset: MouseEventHandler<Button>
}
function ChangePrivilegesActions({
handleReset,
}: ChangePrivilegesActionsProps) {
const { t } = useTranslation()
return (
<div className="text-center">
<Button type="submit" bsSize="sm" bsStyle="primary">
{t('change_or_cancel-change')}
</Button>
<div className="text-sm">
{t('change_or_cancel-or')}
&nbsp;
<Button type="button" className="btn-inline-link" onClick={handleReset}>
{t('change_or_cancel-cancel')}
</Button>
</div>
</div>
)
}

View file

@ -0,0 +1,121 @@
import { useCallback } from 'react'
import PropTypes from 'prop-types'
import { useShareProjectContext } from './share-project-modal'
import Icon from '@/shared/components/icon'
import { Button, Col, Row } from 'react-bootstrap'
import Tooltip from '@/shared/components/tooltip'
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'
export default function Invite({ invite, isProjectOwner }) {
const { t } = useTranslation()
return (
<Row className="project-invite">
<Col xs={8}>
<div>{invite.email}</div>
<div className="small">
{t('invite_not_accepted')}
.&nbsp;
{isProjectOwner && <ResendInvite invite={invite} />}
</div>
</Col>
<Col xs={3} className="text-right">
<MemberPrivileges privileges={invite.privileges} />
</Col>
{isProjectOwner && (
<Col xs={1} className="text-center">
<RevokeInvite invite={invite} />
</Col>
)}
</Row>
)
}
Invite.propTypes = {
invite: PropTypes.object.isRequired,
isProjectOwner: PropTypes.bool.isRequired,
}
function ResendInvite({ invite }) {
const { t } = useTranslation()
const { monitorRequest } = useShareProjectContext()
const { _id: projectId } = useProjectContext()
// const buttonRef = useRef(null)
//
const handleClick = useCallback(
() =>
monitorRequest(() => resendInvite(projectId, invite)).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]
)
return (
<Button
bsStyle="link"
className="btn-inline-link"
onClick={handleClick}
// ref={buttonRef}
>
{t('resend')}
</Button>
)
}
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 (
<Tooltip
id="revoke-invite"
description={t('revoke_invite')}
overlayProps={{ placement: 'bottom' }}
>
<Button
type="button"
bsStyle="link"
onClick={handleClick}
aria-label={t('revoke')}
className="btn-inline-link"
>
<Icon type="times" />
</Button>
</Tooltip>
)
}
RevokeInvite.propTypes = {
invite: PropTypes.object.isRequired,
}

View file

@ -0,0 +1,311 @@
import { useCallback, useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import { Button, Col, Row } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/shared/components/tooltip'
import Icon from '@/shared/components/icon'
import { useShareProjectContext } from './share-project-modal'
import { setProjectAccessLevel } from '../../utils/api'
import { CopyToClipboard } from '@/shared/components/copy-to-clipboard'
import { useProjectContext } from '@/shared/context/project-context'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import { useUserContext } from '@/shared/context/user-context'
import { sendMB } from '../../../../infrastructure/event-tracking'
import { getJSON } from '../../../../infrastructure/fetch-json'
import useAbortController from '@/shared/hooks/use-abort-controller'
import { debugConsole } from '@/utils/debugging'
export default function LinkSharing() {
const [inflight, setInflight] = useState(false)
const [showLinks, setShowLinks] = useState(false)
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}
/>
)
// 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 }) {
const { t } = useTranslation()
return (
<Row className="public-access-level">
<Col xs={12} className="text-center">
<strong>{t('link_sharing_is_off_short')}</strong>
<span>&nbsp;&nbsp;</span>
<Button
type="button"
bsStyle="link"
className="btn-inline-link"
onClick={() => {
setAccessLevel('tokenBased')
eventTracking.sendMB('link-sharing-click', { projectId })
}}
disabled={inflight}
>
{t('turn_on_link_sharing')}
</Button>
<span>&nbsp;&nbsp;</span>
<LinkSharingInfo />
</Col>
</Row>
)
}
PrivateSharing.propTypes = {
setAccessLevel: PropTypes.func.isRequired,
inflight: PropTypes.bool,
projectId: PropTypes.string,
}
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 (
<Row className="public-access-level">
<Col xs={12} className="text-center">
<strong>{t('link_sharing_is_on')}</strong>
<span>&nbsp;&nbsp;</span>
<Button
bsStyle="link"
className="btn-inline-link"
onClick={() => setAccessLevel('private')}
disabled={inflight}
>
{t('turn_off_link_sharing')}
</Button>
<span>&nbsp;&nbsp;</span>
<LinkSharingInfo />
<Button
bsStyle="link"
className="btn-chevron"
onClick={() => setShowLinks(!showLinks)}
>
<Icon type={showLinks ? 'chevron-up' : 'chevron-down'} fw />
</Button>
</Col>
{showLinks && (
<>
<Col 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>
</Col>
</>
)}
</Row>
)
}
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 (
<Row className="public-access-level">
<Col 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>
<Button
type="button"
bsStyle="link"
className="btn-inline-link"
onClick={() => setAccessLevel('private')}
disabled={inflight}
>
{t('make_private')}
</Button>
<span>&nbsp;&nbsp;</span>
<LinkSharingInfo />
</Col>
</Row>
)
}
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 (
<Row className="public-access-level">
<Col xs={12} 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>
</Col>
</Row>
)
}
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 = window.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 (
<Tooltip
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"
>
<Icon type="question-circle" />
</a>
</Tooltip>
)
}

View file

@ -0,0 +1,20 @@
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
export default function MemberPrivileges({ privileges }) {
const { t } = useTranslation()
switch (privileges) {
case 'readAndWrite':
return t('can_edit')
case 'readOnly':
return t('read_only')
default:
return null
}
}
MemberPrivileges.propTypes = {
privileges: PropTypes.string.isRequired,
}

View file

@ -0,0 +1,23 @@
import { useProjectContext } from '@/shared/context/project-context'
import { Col, Row } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import Icon from '@/shared/components/icon'
export default function OwnerInfo() {
const { t } = useTranslation()
const { owner } = useProjectContext()
return (
<Row className="project-member">
<Col xs={9}>
<div className="project-member-email-icon">
<Icon type="user" fw />
<div className="email-warning">{owner?.email}</div>
</div>
</Col>
<Col xs={3} className="text-left">
{t('owner')}
</Col>
</Row>
)
}

View file

@ -0,0 +1,357 @@
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 { FormControl } from 'react-bootstrap'
import Icon from '@/shared/components/icon'
// 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,
privileges,
setPrivileges,
readOnly,
}) {
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])
const {
isOpen,
getLabelProps,
getMenuProps,
getInputProps,
getComboboxProps,
highlightedIndex,
getItemProps,
reset,
} = useCombobox({
inputValue,
defaultHighlightedIndex: 0,
items: filteredOptions,
itemToString: item => item && item.name,
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 (readOnly) {
setPrivileges('readOnly')
}
if (isOpen && filteredOptions.length === 0) {
reset()
}
}, [reset, isOpen, filteredOptions.length, readOnly, setPrivileges])
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 && <Icon type="refresh" spin />}
</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">
<div className="tags-main">
{selectedItems.map((selectedItem, index) => (
<SelectedItem
key={`selected-item-${index}`}
removeSelectedItem={removeSelectedItem}
selectedItem={selectedItem}
focusInput={focusInput}
index={index}
getSelectedItemProps={getSelectedItemProps}
/>
))}
<input
{...getInputProps(
getDropdownProps({
className: classnames({
input: true,
'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>
<FormControl
componentClass="select"
className="privileges"
bsSize="sm"
value={privileges}
onChange={event => setPrivileges(event.target.value)}
>
{!readOnly && <option value="readAndWrite">{t('can_edit')}</option>}
<option value="readOnly">{t('read_only')}</option>
</FormControl>
</div>
<div className={classnames({ autocomplete: isOpen })}>
<ul {...getMenuProps()} className="suggestion-list">
{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,
privileges: PropTypes.string.isRequired,
setPrivileges: PropTypes.func.isRequired,
readOnly: PropTypes.bool.isRequired,
}
function Option({ selected, item, getItemProps, index }) {
return (
<li
className={classnames('suggestion-item', { selected })}
{...getItemProps({ item, index })}
>
<Icon type="user" fw />
&nbsp;
{item.display}
</li>
)
}
Option.propTypes = {
selected: PropTypes.bool.isRequired,
item: PropTypes.shape({
display: PropTypes.string.isRequired,
}),
index: PropTypes.number.isRequired,
getItemProps: PropTypes.func.isRequired,
}
function SelectedItem({
removeSelectedItem,
selectedItem,
focusInput,
getSelectedItemProps,
index,
}) {
const { t } = useTranslation()
const handleClick = useCallback(
event => {
event.preventDefault()
event.stopPropagation()
removeSelectedItem(selectedItem)
focusInput()
},
[focusInput, removeSelectedItem, selectedItem]
)
return (
<span
className="tag-item"
{...getSelectedItemProps({ selectedItem, index })}
>
<Icon type="user" fw />
<span>{selectedItem.display}</span>
<button
type="button"
className="remove-button btn-inline-link"
aria-label={t('remove')}
onClick={handleClick}
>
<Icon type="close" fw />
</button>
</span>
)
}
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,33 @@
import { Col, Row } from 'react-bootstrap'
import PropTypes from 'prop-types'
import { useTranslation } from 'react-i18next'
import { useProjectContext } from '@/shared/context/project-context'
export default function SendInvitesNotice() {
const { publicAccessLevel } = useProjectContext()
return (
<Row className="public-access-level public-access-level--notice">
<Col xs={12} className="text-center">
<AccessLevel level={publicAccessLevel} />
</Col>
</Row>
)
}
function AccessLevel({ level }) {
const { t } = useTranslation()
switch (level) {
case 'private':
return t('to_add_more_collaborators')
case 'tokenBased':
return t('to_change_access_permissions')
default:
return null
}
}
AccessLevel.propTypes = {
level: PropTypes.string,
}

View file

@ -0,0 +1,27 @@
import { Row } from 'react-bootstrap'
import AddCollaborators from './add-collaborators'
import AddCollaboratorsUpgrade from './add-collaborators-upgrade'
import CollaboratorsLimitUpgrade from './collaborators-limit-upgrade'
import PropTypes from 'prop-types'
export default function SendInvites({
canAddCollaborators,
hasExceededCollaboratorLimit,
}) {
return (
<Row className="invite-controls">
{hasExceededCollaboratorLimit && <AddCollaboratorsUpgrade />}
{!canAddCollaborators && !hasExceededCollaboratorLimit && (
<CollaboratorsLimitUpgrade />
)}
{!hasExceededCollaboratorLimit && (
<AddCollaborators readOnly={!canAddCollaborators} />
)}
</Row>
)
}
SendInvites.propTypes = {
canAddCollaborators: PropTypes.bool,
hasExceededCollaboratorLimit: PropTypes.bool,
}

View file

@ -0,0 +1,90 @@
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
}
return (
members.filter(member => member.privileges === 'readAndWrite').length +
invites.length <
(features.collaborators ?? 1)
)
}, [members, invites, features, isProjectOwner])
const hasExceededCollaboratorLimit = useMemo(() => {
if (!isProjectOwner || !features) {
return false
}
if (features.collaborators === -1) {
return false
}
return (
members.filter(member => member.privileges === 'readAndWrite').length >
(features.collaborators ?? 1)
)
}, [features, isProjectOwner, members])
return (
<>
{isProjectOwner ? (
<SendInvites
canAddCollaborators={canAddCollaborators}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
/>
) : (
<SendInvitesNotice />
)}
{isProjectOwner && <LinkSharing />}
<OwnerInfo />
{members.map(member =>
isProjectOwner ? (
<EditMember
key={member._id}
member={member}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
canAddCollaborators={canAddCollaborators}
/>
) : (
<ViewMember key={member._id} member={member} />
)
)}
{invites.map(invite => (
<Invite
key={invite._id}
invite={invite}
isProjectOwner={isProjectOwner}
/>
))}
{!getMeta('ol-ExposedSettings').recaptchaDisabled?.invite && (
<RecaptchaConditions />
)}
</>
)
}

View file

@ -0,0 +1,104 @@
import { Button, Modal, Grid } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import Icon from '@/shared/components/icon'
import AccessibleModal from '@/shared/components/accessible-modal'
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'
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 (
<AccessibleModal show={show} onHide={cancel} animation={animation}>
<Modal.Header closeButton>
<Modal.Title>{t('share_project')}</Modal.Title>
</Modal.Header>
<Modal.Body className="modal-body-share">
<Grid fluid>
<Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
{isRestrictedTokenMember ? (
<ReadOnlyTokenLink />
) : (
<ShareModalBody />
)}
</Suspense>
</Grid>
</Modal.Body>
<Modal.Footer className="modal-footer-share">
<div className="modal-footer-left">
{inFlight && <Icon type="refresh" spin />}
{error && (
<span className="text-danger error">
<ErrorMessage error={error} />
</span>
)}
</div>
<div className="modal-footer-right">
<ClickableElementEnhancer
onClick={cancel}
as={Button}
type="button"
bsStyle={null}
className="btn-secondary"
disabled={inFlight}
>
{t('close')}
</ClickableElementEnhancer>
</div>
</Modal.Footer>
</AccessibleModal>
)
}
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')}</>
default:
return <>{t('generic_something_went_wrong')}</>
}
}

View file

@ -0,0 +1,140 @@
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'
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 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
animation?: boolean
}
const ShareProjectModal = React.memo(function ShareProjectModal({
handleHide,
show,
animation = true,
}: ShareProjectModalProps) {
const [inFlight, setInFlight] =
useState<ShareProjectContextValue['inFlight']>(false)
const [error, setError] = useState<ShareProjectContextValue['error']>()
const project = useProjectContext()
const { splitTestVariants } = useSplitTestContext()
// 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,86 @@
import { useState } from 'react'
import { Modal, Button } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
import Icon from '@/shared/components/icon'
import { transferProjectOwnership } from '../../utils/api'
import AccessibleModal from '@/shared/components/accessible-modal'
import { useProjectContext } from '@/shared/context/project-context'
import { useLocation } from '@/shared/hooks/use-location'
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 (
<AccessibleModal show onHide={cancel}>
<Modal.Header closeButton>
<Modal.Title>{t('change_project_owner')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<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>
</Modal.Body>
<Modal.Footer>
<div className="modal-footer-left">
{inflight && <Icon type="refresh" spin />}
{error && (
<span className="text-danger">
{t('generic_something_went_wrong')}
</span>
)}
</div>
<div className="modal-footer-right">
<Button
type="button"
bsStyle={null}
className="btn-secondary"
onClick={cancel}
disabled={inflight}
>
{t('cancel')}
</Button>
<Button
type="button"
bsStyle="primary"
onClick={confirm}
disabled={inflight}
>
{t('change_owner')}
</Button>
</div>
</Modal.Footer>
</AccessibleModal>
)
}
TransferOwnershipModal.propTypes = {
member: PropTypes.object.isRequired,
cancel: PropTypes.func.isRequired,
}

View file

@ -0,0 +1,22 @@
import PropTypes from 'prop-types'
import { Col, Row } from 'react-bootstrap'
import MemberPrivileges from './member-privileges'
export default function ViewMember({ member }) {
return (
<Row className="project-member">
<Col xs={7}>{member.email}</Col>
<Col xs={3}>
<MemberPrivileges privileges={member.privileges} />
</Col>
</Row>
)
}
ViewMember.propTypes = {
member: PropTypes.shape({
_id: PropTypes.string.isRequired,
email: PropTypes.string.isRequired,
privileges: PropTypes.string.isRequired,
}).isRequired,
}

View file

@ -6,6 +6,7 @@ import {
KeyboardEventHandler,
useCallback,
ReactNode,
useState,
} from 'react'
import classNames from 'classnames'
import { useSelect } from 'downshift'
@ -34,12 +35,18 @@ export type SelectProps<T> = {
itemToKey: (item: T) => string
// Callback invoked after the selected item is updated.
onSelectedItemChanged?: (item: T | null | undefined) => void
// Optionally directly control the selected item.
selected?: T
// When `true` item selection is disabled.
disabled?: boolean
// Determine which items should be disabled
itemToDisabled?: (item: T | null | undefined) => boolean
// When `true` displays an "Optional" subtext after the `label` caption.
optionalLabel?: boolean
// When `true` displays a spinner next to the `label` caption.
loading?: boolean
// Show a checkmark next to the selected item
selectedIcon?: boolean
}
export const Select = <T,>({
@ -52,14 +59,20 @@ export const Select = <T,>({
itemToSubtitle,
itemToKey,
onSelectedItemChanged,
selected,
disabled = false,
itemToDisabled,
optionalLabel = false,
loading = false,
selectedIcon = false,
}: SelectProps<T>) => {
const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
defaultItem
)
const { t } = useTranslation()
const {
isOpen,
selectedItem,
getToggleButtonProps,
getLabelProps,
getMenuProps,
@ -69,13 +82,19 @@ export const Select = <T,>({
} = useSelect({
items: items ?? [],
itemToString,
selectedItem: selected || defaultItem,
onSelectedItemChange: changes => {
if (onSelectedItemChanged) {
onSelectedItemChanged(changes.selectedItem)
}
setSelectedItem(changes.selectedItem)
},
})
useEffect(() => {
setSelectedItem(selected)
}, [selected])
const rootRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!name || !rootRef.current) return
@ -153,23 +172,39 @@ export const Select = <T,>({
{...getMenuProps({ disabled })}
>
{isOpen &&
items?.map((item, index) => (
<li
className={classNames({
'select-highlighted': highlightedIndex === index,
'selected-active': selectedItem === item,
})}
key={itemToKey(item)}
{...getItemProps({ item, index })}
>
<span className="select-item-title">{itemToString(item)}</span>
{itemToSubtitle ? (
<span className="text-muted select-item-subtitle">
{itemToSubtitle(item)}
items?.map((item, index) => {
const isDisabled = itemToDisabled && itemToDisabled(item)
return (
<li
className={classNames({
'select-highlighted': highlightedIndex === index,
'selected-active': selectedItem === item,
'select-icon': selectedIcon,
'select-disabled': isDisabled,
})}
key={itemToKey(item)}
{...getItemProps({ item, index, disabled: isDisabled })}
>
<span className="select-item-title">
{selectedIcon && (
<div className="select-item-icon">
{(selectedItem === item ||
(!selectedItem && defaultItem === item)) && (
<Icon type="check" fw />
)}
</div>
)}
{itemToString(item)}
</span>
) : null}
</li>
))}
{itemToSubtitle ? (
<span className="text-muted select-item-subtitle">
{itemToSubtitle(item)}
</span>
) : null}
</li>
)
})}
</ul>
</div>
)

View file

@ -1,45 +1,9 @@
import { FC, createContext, useContext, useMemo } from 'react'
import useScopeValue from '../hooks/use-scope-value'
import getMeta from '@/utils/meta'
import { UserId } from '../../../../types/user'
import { PublicAccessLevel } from '../../../../types/public-access-level'
import type * as ReviewPanel from '@/features/source-editor/context/review-panel/types/review-panel-state'
import { ProjectContextValue } from './types/project-context'
const ProjectContext = createContext<
| {
_id: string
name: string
rootDocId?: string
compiler: string
members: { _id: UserId; email: string; privileges: string }[]
invites: { _id: UserId }[]
features: {
collaborators?: number
compileGroup?: 'alpha' | 'standard' | 'priority'
trackChanges?: boolean
trackChangesVisible?: boolean
references?: boolean
mendeley?: boolean
zotero?: boolean
versioning?: boolean
gitBridge?: boolean
referencesSearch?: boolean
github?: boolean
}
publicAccessLevel?: PublicAccessLevel
owner: {
_id: UserId
email: string
}
tags: {
_id: string
name: string
color?: string
}[]
trackChangesState: ReviewPanel.Value<'trackChangesState'>
}
| undefined
>(undefined)
const ProjectContext = createContext<ProjectContextValue | undefined>(undefined)
export function useProjectContext() {
const context = useContext(ProjectContext)

View file

@ -0,0 +1,44 @@
import { UserId } from '../../../../../types/user'
import { PublicAccessLevel } from '../../../../../types/public-access-level'
import type * as ReviewPanel from '@/features/source-editor/context/review-panel/types/review-panel-state'
export type ProjectContextMember = {
_id: UserId
privileges: 'readOnly' | 'readAndWrite'
email: string
}
export type ProjectContextValue = {
_id: string
name: string
rootDocId?: string
compiler: string
members: ProjectContextMember[]
invites: { _id: UserId }[]
features: {
collaborators?: number
compileGroup?: 'alpha' | 'standard' | 'priority'
trackChanges?: boolean
trackChangesVisible?: boolean
references?: boolean
mendeley?: boolean
zotero?: boolean
versioning?: boolean
gitBridge?: boolean
referencesSearch?: boolean
github?: boolean
}
publicAccessLevel?: PublicAccessLevel
owner: {
_id: UserId
email: string
}
tags: {
_id: string
name: string
color?: string
}[]
trackChangesState: ReviewPanel.Value<'trackChangesState'>
}
export type ProjectContextUpdateValue = Partial<ProjectContextValue>

View file

@ -123,6 +123,7 @@ export interface Meta {
'ol-languages': SpellCheckLanguage[]
'ol-learnedWords': string[]
'ol-legacyEditorThemes': string[]
'ol-linkSharingWarning': boolean
'ol-loadingText': string
'ol-managedGroupSubscriptions': ManagedGroupSubscription[]
'ol-managedInstitutions': ManagedInstitution[]

View file

@ -29,6 +29,32 @@ export const WithSubtitles = () => {
)
}
export const WithSelectedIcon = () => {
return (
<Select
items={items}
itemToString={x => String(x?.value)}
itemToKey={x => String(x.key)}
itemToSubtitle={x => x?.group ?? ''}
defaultText="Choose an item"
selectedIcon
/>
)
}
export const WithDisabledItem = () => {
return (
<Select
items={items}
itemToString={x => String(x?.value)}
itemToKey={x => String(x.key)}
itemToDisabled={x => x?.key === 1}
itemToSubtitle={x => x?.group ?? ''}
defaultText="Choose an item"
/>
)
}
export default {
title: 'Shared / Components / Select',
component: Select,

View file

@ -18,16 +18,21 @@
font-size: inherit;
}
.project-member,
.project-member {
padding: (@line-height-computed / 2) 0;
font-size: 16px;
span {
padding-right: @line-height-computed / 2;
}
}
.project-invite,
.public-access-level {
padding: (@line-height-computed / 2) 0;
border-bottom: 1px solid @gray-lighter;
font-size: 14px;
}
.public-access-level {
padding-top: 0;
margin-top: @line-height-computed / 4;
font-size: 13px;
padding-bottom: @modal-inner-padding;
.access-token-display-area {
@ -45,6 +50,14 @@
}
}
}
.fa-chevron-down,
.fa-chevron-up {
vertical-align: top;
color: @neutral-70;
}
.btn-chevron {
padding: 0 (@line-height-computed / 2);
}
}
.public-access-level.public-access-level--notice {
@ -61,14 +74,50 @@
}
}
.project-member {
.select-trigger {
color: @neutral-70;
border: none;
padding: 0 10px 5px 10px;
}
.select-items {
max-height: 300px;
width: 250%;
li:last-child {
color: @brand-danger;
}
}
.project-member-email-icon {
display: grid;
grid-template-columns: 2em auto;
align-items: center;
padding-bottom: 5px;
.fa-warning {
color: @brand-warning;
}
.subtitle {
font-size: @font-size-small;
}
}
}
.project-member .text-left,
.project-invite .text-left {
padding-left: 25px;
}
.invite-controls {
.small {
padding: 2px;
margin-bottom: 0;
}
padding: @line-height-computed / 2;
background-color: @gray-lightest;
margin-top: @line-height-computed / 2;
.add-collabs {
margin-top: @line-height-computed / 2;
}
form {
.form-group {
margin-bottom: @line-height-computed / 2;
@ -79,14 +128,25 @@
.privileges {
display: inline-block;
width: auto;
}
.tags-new .privileges {
background: transparent;
width: auto;
height: 30px;
font-size: 14px;
border: none;
border-right: 5px solid transparent;
}
}
.add-collaborators-upgrade {
display: flex;
flex-direction: column;
align-items: center;
.upgrade-actions {
display: flex;
gap: @margin-md;
}
}
}

View file

@ -14,7 +14,7 @@
}
}
.select-highlighted {
.select-highlighted:not(.select-disabled) {
background-color: @neutral-10;
border-radius: 4px;
}
@ -23,6 +23,11 @@
font-weight: bold;
}
.select-disabled {
color: @text-muted;
cursor: not-allowed;
}
.select-items {
list-style: none;
width: 100%;
@ -46,14 +51,25 @@
.select-item-title,
.select-item-subtitle {
display: block;
cursor: default;
user-select: none;
:not(.select-disabled) {
cursor: default;
}
}
.select-item-icon {
width: 30px;
display: inline-block;
}
.select-item-subtitle {
font-size: 0.9rem;
}
.select-icon .select-item-subtitle {
margin-left: 30px;
}
.select-optional-label {
font-weight: normal;
}

View file

@ -21,15 +21,18 @@
.tags-input .tags {
.form-control;
appearance: textfield;
-moz-appearance: textfield;
-webkit-appearance: textfield;
padding: 0 0 0 5px;
overflow: hidden;
word-wrap: break-word;
cursor: text;
background-color: #fff;
height: 100%;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
flex-direction: row;
}
.tags-input .tags:focus-within {
&:extend(.input-focus-style);

View file

@ -71,6 +71,7 @@
"add_more_members": "Add more members",
"add_new_email": "Add new email",
"add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
"add_people": "Add people",
"add_role_and_department": "Add role and department",
"add_to_tag": "Add to tag",
"add_your_comment_here": "Add your comment here",
@ -501,8 +502,10 @@
"editing": "Editing",
"editing_and_collaboration": "Editing and collaboration",
"editing_captions": "Editing captions",
"editor": "Editor",
"editor_and_pdf": "Editor & PDF",
"editor_disconected_click_to_reconnect": "Editor disconnected, click anywhere to reconnect.",
"editor_limit_exceeded_in_this_project": "Too many editors in this project",
"editor_only_hide_pdf": "Editor only <0>(hide PDF)</0>",
"editor_resources": "Editor Resources",
"editor_theme": "Editor theme",
@ -939,6 +942,7 @@
"invalid_password_too_similar": "Password is too similar to parts of email address",
"invalid_request": "Invalid Request. Please correct the data and try again.",
"invalid_zip_file": "Invalid zip file",
"invite": "Invite",
"invite_more_collabs": "Invite more collaborators",
"invite_not_accepted": "Invite not yet accepted",
"invite_not_valid": "This is not a valid project invite",
@ -1034,6 +1038,7 @@
"license": "License",
"license_for_educational_purposes": "This license is for educational purposes (applies to students or faculty using __appName__ for teaching)",
"limited_offer": "Limited offer",
"limited_to_n_editors_per_project": "Limited to n editors per project",
"line_height": "Line Height",
"line_width_is_the_width_of_the_line_in_the_current_environment": "Line width is the width of the line in the current environment. e.g. a full page width in single-column layout or half a page width in a two-column layout.",
"link": "Link",
@ -1043,6 +1048,7 @@
"link_institutional_email_get_started": "Link an institutional email address to your account to get started.",
"link_sharing": "Link sharing",
"link_sharing_is_off": "Link sharing is off, only invited users can view this project.",
"link_sharing_is_off_short": "Link sharing is off",
"link_sharing_is_on": "Link sharing is on",
"link_to_github": "Link to your GitHub account",
"link_to_github_description": "You need to authorise __appName__ to access your GitHub account to allow us to sync your projects.",
@ -1108,6 +1114,7 @@
"maintenance": "Maintenance",
"make_a_copy": "Make a copy",
"make_email_primary_description": "Make this the primary email, used to log in",
"make_owner": "Make owner",
"make_primary": "Make Primary",
"make_private": "Make Private",
"manage_beta_program_membership": "Manage Beta Program Membership",
@ -1495,6 +1502,7 @@
"react_history_tutorial_title": "History actions have a new home",
"reactivate_subscription": "Reactivate your subscription",
"read_lines_from_path": "Read lines from __path__",
"read_more": "Read more",
"read_more_about_free_compile_timeouts_servers": "Read more about changes to free compile timeouts and servers",
"read_only": "Read Only",
"read_only_token": "Read-Only Token",
@ -1556,6 +1564,7 @@
"remind_before_trial_ends": "Well remind you before your trial ends",
"remote_service_error": "The remote service produced an error",
"remove": "Remove",
"remove_access": "Remove access",
"remove_collaborator": "Remove collaborator",
"remove_from_group": "Remove from group",
"remove_link": "Remove link",
@ -2145,6 +2154,7 @@
"upgrade_cc_btn": "Upgrade now, pay after 7 days",
"upgrade_for_12x_more_compile_time": "Upgrade to get 12x more compile time",
"upgrade_now": "Upgrade Now",
"upgrade_to_add_more_editors": "Upgrade to add more editors to your project",
"upgrade_to_get_feature": "Upgrade to get __feature__, plus:",
"upgrade_to_track_changes": "Upgrade to track changes",
"upload": "Upload",
@ -2202,6 +2212,7 @@
"view_pdf": "View PDF",
"view_source": "View Source",
"view_your_invoices": "View Your Invoices",
"viewer": "Viewer",
"viewing_x": "Viewing <0>__endTime__</0>",
"visual_editor": "Visual Editor",
"visual_editor_is_only_available_for_tex_files": "Visual Editor is only available for TeX files",
@ -2230,6 +2241,7 @@
"when_you_tick_the_include_caption_box": "When you tick the box “Include caption” the image will be inserted into your document with a placeholder caption. To edit it, you simply select the placeholder text and type to replace it with your own.",
"why_latex": "Why LaTeX?",
"wide": "Wide",
"will_lose_edit_access_on_date": "Will lose edit access on __date__",
"will_need_to_log_out_from_and_in_with": "You will need to <b>log out</b> from your <b>__email1__</b> account and then log in with <b>__email2__</b>.",
"with_premium_subscription_you_also_get": "With an Overleaf Premium subscription you also get",
"word_count": "Word Count",
@ -2279,6 +2291,7 @@
"you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are on our <0>__planName__</0> plan as a <1>member</1> of the group subscription <1>__groupName__</1> administered by <1>__adminEmail__</1>",
"you_can_now_enable_sso": "You can now enable SSO on your Group settings page.",
"you_can_now_log_in_sso": "You can now log in through your institution and if eligible you will receive <0>__appName__ Professional features</0>.",
"you_can_only_add_n_people_to_edit_a_project": "You can only add X people to edit a project with you on your current plan. Upgrade to add more.",
"you_can_opt_in_and_out_of_the_program_at_any_time_on_this_page": "You can <0>opt in and out</0> of the program at any time on this page",
"you_can_request_a_maximum_of_limit_fixes_per_day": "You can request a maximum of __limit__ fixes per day. Please try again tomorrow.",
"you_cant_add_or_change_password_due_to_sso": "You cant add or change your password because your group or organization uses <0>single sign-on (SSO)</0>.",
@ -2319,6 +2332,7 @@
"your_password_was_detected": "Your password is on a <0>public list of known compromised passwords</0>. Keep your account safe by changing your password now.",
"your_plan": "Your plan",
"your_plan_is_changing_at_term_end": "Your plan is changing to <0>__pendingPlanName__</0> at the end of the current billing period.",
"your_plan_is_limited_to_n_editors": "Your plan allows [n] collaborators with edit access and unlimited viewers. From [date] any additional editors on this project will be made viewers.",
"your_project_exceeded_compile_timeout_limit_on_free_plan": "Your project exceeded the compile timeout limit on our free plan.",
"your_project_near_compile_timeout_limit": "Your project is near the compile timeout limit for our free plan.",
"your_projects": "Your Projects",

View file

@ -44,9 +44,12 @@ function render(props: RenderProps) {
itemToSubtitle={props.itemToSubtitle}
itemToKey={x => String(x.key)}
onSelectedItemChanged={props.onSelectedItemChanged}
selected={props.selected}
disabled={props.disabled}
itemToDisabled={props.itemToDisabled}
optionalLabel={props.optionalLabel}
loading={props.loading}
selectedIcon={props.selectedIcon}
/>
<button type="submit">submit</button>
</form>
@ -275,4 +278,55 @@ describe('<Select />', function () {
cy.findByText('Demo item 2').should('not.exist')
})
})
describe('selectedIcon', function () {
it('renders a selected icon if the prop is set', function () {
render({
defaultText: 'Choose an item',
selectedIcon: true,
})
cy.findByText('Choose an item').click()
cy.findByText('Demo item 1').click()
cy.findByText('Demo item 1').click()
cy.get('.fa-check').should('exist')
})
it('renders no selected icon if the prop is not set', function () {
render({
defaultText: 'Choose an item',
selectedIcon: false,
})
cy.findByText('Choose an item').click()
cy.findByText('Demo item 1').click()
cy.findByText('Demo item 1').click()
cy.get('.fa-check').should('not.exist')
})
})
describe('itemToDisabled', function () {
it('prevents selecting a disabled item', function () {
render({
defaultText: 'Choose an item',
itemToDisabled: x => x?.key === 2,
})
cy.findByText('Choose an item').click()
cy.findByText('Demo item 2').click()
// still showing other list items
cy.findByText('Demo item 3').should('exist')
cy.findByText('Demo item 1').click()
// clicking an enabled item dismisses the list
cy.findByText('Demo item 3').should('not.exist')
})
})
describe('selected', function () {
it('shows the item provided in the selected prop', function () {
render({
defaultText: 'Choose an item',
selected: testData[1],
})
cy.findByText('Demo item 2').should('exist')
})
})
})

View file

@ -164,6 +164,7 @@ describe('ProjectController', function () {
this.SplitTestHandler = {
promises: {
getAssignment: sinon.stub().resolves({ variant: 'default' }),
getAssignmentForUser: sinon.stub().resolves({ variant: 'default' }),
},
getAssignment: sinon.stub().yields(null, { variant: 'default' }),
}

View file

@ -1,7 +1,7 @@
import { MongoUser } from './user'
import { Folder } from './folder'
type ProjectMember = {
export type ProjectMember = {
_id: string
type: 'user'
privileges: 'readOnly' | 'readAndWrite'