From fea9ec33f42062fda3abe68a4f665b6840b8eb7f Mon Sep 17 00:00:00 2001 From: roo hutton Date: Tue, 25 Jun 2024 07:08:24 +0100 Subject: [PATCH] Merge pull request #18861 from overleaf/rh-editor-limit-exceeded [web]: Handle exceeded editor limit in share modal GitOrigin-RevId: 23a15805ca98327ae4a7fc731bbca3982c90bad5 --- .../src/Features/Project/ProjectController.js | 7 + .../web/app/views/project/editor/meta.pug | 1 + .../web/frontend/extracted-translations.json | 14 + .../components/editor-navigation-toolbar.tsx | 19 +- .../add-collaborators-upgrade.tsx | 36 ++ .../add-collaborators.jsx | 174 +++++++++ .../collaborators-limit-upgrade.tsx | 28 ++ .../restricted-link-sharing/edit-member.tsx | 242 ++++++++++++ .../restricted-link-sharing/invite.jsx | 121 ++++++ .../restricted-link-sharing/link-sharing.jsx | 311 +++++++++++++++ .../member-privileges.jsx | 20 + .../restricted-link-sharing/owner-info.jsx | 23 ++ .../select-collaborators.jsx | 357 ++++++++++++++++++ .../send-invites-notice.jsx | 33 ++ .../restricted-link-sharing/send-invites.jsx | 27 ++ .../share-modal-body.tsx | 90 +++++ .../share-project-modal-content.tsx | 104 +++++ .../share-project-modal.tsx | 140 +++++++ .../transfer-ownership-modal.jsx | 86 +++++ .../restricted-link-sharing/view-member.jsx | 22 ++ .../frontend/js/shared/components/select.tsx | 69 +++- .../js/shared/context/project-context.tsx | 40 +- .../shared/context/types/project-context.tsx | 44 +++ services/web/frontend/js/utils/meta.ts | 1 + .../web/frontend/stories/select.stories.tsx | 26 ++ .../stylesheets/app/editor/share.less | 74 +++- .../stylesheets/components/select.less | 20 +- .../stylesheets/components/tags-input.less | 5 +- services/web/locales/en.json | 14 + .../components/shared/select.spec.tsx | 54 +++ .../src/Project/ProjectControllerTests.js | 1 + services/web/types/project.ts | 2 +- 32 files changed, 2135 insertions(+), 70 deletions(-) create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators-upgrade.tsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators.jsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/collaborators-limit-upgrade.tsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/edit-member.tsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/invite.jsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/link-sharing.jsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/member-privileges.jsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/owner-info.jsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/select-collaborators.jsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites-notice.jsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites.jsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-modal-body.tsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal-content.tsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal.tsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/transfer-ownership-modal.jsx create mode 100644 services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/view-member.jsx create mode 100644 services/web/frontend/js/shared/context/types/project-context.tsx diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 5ede7b89d0..f74a953b19 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -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) { diff --git a/services/web/app/views/project/editor/meta.pug b/services/web/app/views/project/editor/meta.pug index 5a5ecd8ff8..e639a57a48 100644 --- a/services/web/app/views/project/editor/meta.pug +++ b/services/web/app/views/project/editor/meta.pug @@ -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")) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index a78dd97e8e..dd45f8437f 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx b/services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx index 461ee26d41..f9441d0d94 100644 --- a/services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx +++ b/services/web/frontend/js/features/ide-react/components/editor-navigation-toolbar.tsx @@ -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 ( <> - + {showNewShareModal ? ( + + ) : ( + + )} ) } diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators-upgrade.tsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators-upgrade.tsx new file mode 100644 index 0000000000..9e5d8edcae --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators-upgrade.tsx @@ -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 ( +
+ {t('your_plan_is_limited_to_n_editors')}

} + action={ +
+ + + {t('read_more')} + +
+ } + /> +
+ ) +} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators.jsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators.jsx new file mode 100644 index 0000000000..fe0a8f7cbd --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators.jsx @@ -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 ( +
+ + + + + +
+      + + {t('invite')} + +
+ + + ) +} + +AddCollaborators.propTypes = { + readOnly: PropTypes.bool, +} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/collaborators-limit-upgrade.tsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/collaborators-limit-upgrade.tsx new file mode 100644 index 0000000000..8fed514e20 --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/collaborators-limit-upgrade.tsx @@ -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 ( +
+ } + title={t('upgrade_to_add_more_editors')} + content={

{t('you_can_only_add_n_people_to_edit_a_project')}

} + action={ + + } + /> +
+ ) +} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/edit-member.tsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/edit-member.tsx new file mode 100644 index 0000000000..2f72e17ff1 --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/edit-member.tsx @@ -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( + 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 ( + { + setConfirmingOwnershipTransfer(false) + setPrivileges(member.privileges) + }} + /> + ) + } + + return ( +
{ + e.preventDefault() + commitPrivilegeChange(privileges) + }} + > + + +
+ +
+ {member.email} + {shouldWarnMember() && ( +
+ {t('will_lose_edit_access_on_date', { + date: '[date]', + })} +
+ )} +
+
+ + + + {privileges !== member.privileges && privilegeChangePending && ( + setPrivileges(member.privileges)} + /> + )} + + + + { + value && handlePrivilegeChange(value.key) + }} + canAddCollaborators={canAddCollaborators} + /> + +
+
+ ) +} +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 ( + { + 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) + } + } + } + }, + }) + )} + /> + + setPrivileges(event.target.value)} + > + {!readOnly && } + + + + +
+
    + {isOpen && + filteredOptions.map((item, index) => ( +
+
+ + + ) +} +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 ( +
  • + +   + {item.display} +
  • + ) +} +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 ( + + + {selectedItem.display} + + + ) +} +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, +} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites-notice.jsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites-notice.jsx new file mode 100644 index 0000000000..d3d5d92743 --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites-notice.jsx @@ -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 ( + + + + + + ) +} + +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, +} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites.jsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites.jsx new file mode 100644 index 0000000000..a31194755d --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites.jsx @@ -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 ( + + {hasExceededCollaboratorLimit && } + {!canAddCollaborators && !hasExceededCollaboratorLimit && ( + + )} + {!hasExceededCollaboratorLimit && ( + + )} + + ) +} + +SendInvites.propTypes = { + canAddCollaborators: PropTypes.bool, + hasExceededCollaboratorLimit: PropTypes.bool, +} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-modal-body.tsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-modal-body.tsx new file mode 100644 index 0000000000..a6c04f90a0 --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-modal-body.tsx @@ -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 ? ( + + ) : ( + + )} + {isProjectOwner && } + + + + {members.map(member => + isProjectOwner ? ( + + ) : ( + + ) + )} + + {invites.map(invite => ( + + ))} + + {!getMeta('ol-ExposedSettings').recaptchaDisabled?.invite && ( + + )} + + ) +} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal-content.tsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal-content.tsx new file mode 100644 index 0000000000..67e270509e --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal-content.tsx @@ -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 ( + + + {t('share_project')} + + + + + }> + {isRestrictedTokenMember ? ( + + ) : ( + + )} + + + + + +
    + {inFlight && } + {error && ( + + + + )} +
    + +
    + + {t('close')} + +
    +
    +
    + ) +} + +function ErrorMessage({ error }: Pick) { + 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')} + } +} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal.tsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal.tsx new file mode 100644 index 0000000000..d0d79d4164 --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal.tsx @@ -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: >(request: () => T) => T + inFlight: boolean + setInFlight: React.Dispatch< + React.SetStateAction + > + error: string | undefined + setError: React.Dispatch< + React.SetStateAction + > +} + +const ShareProjectContext = createContext( + 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(false) + const [error, setError] = useState() + + 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 }) => { + 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 ( + + + + ) +}) + +export default ShareProjectModal diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/transfer-ownership-modal.jsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/transfer-ownership-modal.jsx new file mode 100644 index 0000000000..41ad759f87 --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/transfer-ownership-modal.jsx @@ -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 ( + + + {t('change_project_owner')} + + +

    + , ]} + shouldUnescape + tOptions={{ interpolation: { escapeValue: true } }} + /> +

    +

    {t('project_ownership_transfer_confirmation_2')}

    +
    + +
    + {inflight && } + {error && ( + + {t('generic_something_went_wrong')} + + )} +
    +
    + + +
    +
    +
    + ) +} +TransferOwnershipModal.propTypes = { + member: PropTypes.object.isRequired, + cancel: PropTypes.func.isRequired, +} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/view-member.jsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/view-member.jsx new file mode 100644 index 0000000000..5d5c17956a --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/view-member.jsx @@ -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 ( + + {member.email} + + + + + ) +} + +ViewMember.propTypes = { + member: PropTypes.shape({ + _id: PropTypes.string.isRequired, + email: PropTypes.string.isRequired, + privileges: PropTypes.string.isRequired, + }).isRequired, +} diff --git a/services/web/frontend/js/shared/components/select.tsx b/services/web/frontend/js/shared/components/select.tsx index af06a563e9..f657d2cda9 100644 --- a/services/web/frontend/js/shared/components/select.tsx +++ b/services/web/frontend/js/shared/components/select.tsx @@ -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 = { 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 = ({ @@ -52,14 +59,20 @@ export const Select = ({ itemToSubtitle, itemToKey, onSelectedItemChanged, + selected, disabled = false, + itemToDisabled, optionalLabel = false, loading = false, + selectedIcon = false, }: SelectProps) => { + const [selectedItem, setSelectedItem] = useState( + defaultItem + ) + const { t } = useTranslation() const { isOpen, - selectedItem, getToggleButtonProps, getLabelProps, getMenuProps, @@ -69,13 +82,19 @@ export const Select = ({ } = useSelect({ items: items ?? [], itemToString, + selectedItem: selected || defaultItem, onSelectedItemChange: changes => { if (onSelectedItemChanged) { onSelectedItemChanged(changes.selectedItem) } + setSelectedItem(changes.selectedItem) }, }) + useEffect(() => { + setSelectedItem(selected) + }, [selected]) + const rootRef = useRef(null) useEffect(() => { if (!name || !rootRef.current) return @@ -153,23 +172,39 @@ export const Select = ({ {...getMenuProps({ disabled })} > {isOpen && - items?.map((item, index) => ( -
  • - {itemToString(item)} - {itemToSubtitle ? ( - - {itemToSubtitle(item)} + items?.map((item, index) => { + const isDisabled = itemToDisabled && itemToDisabled(item) + return ( +
  • + + {selectedIcon && ( +
    + {(selectedItem === item || + (!selectedItem && defaultItem === item)) && ( + + )} +
    + )} + {itemToString(item)}
    - ) : null} -
  • - ))} + + {itemToSubtitle ? ( + + {itemToSubtitle(item)} + + ) : null} + + ) + })} ) diff --git a/services/web/frontend/js/shared/context/project-context.tsx b/services/web/frontend/js/shared/context/project-context.tsx index 9c6a9349eb..358ddcc30b 100644 --- a/services/web/frontend/js/shared/context/project-context.tsx +++ b/services/web/frontend/js/shared/context/project-context.tsx @@ -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(undefined) export function useProjectContext() { const context = useContext(ProjectContext) diff --git a/services/web/frontend/js/shared/context/types/project-context.tsx b/services/web/frontend/js/shared/context/types/project-context.tsx new file mode 100644 index 0000000000..de8e73d543 --- /dev/null +++ b/services/web/frontend/js/shared/context/types/project-context.tsx @@ -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 diff --git a/services/web/frontend/js/utils/meta.ts b/services/web/frontend/js/utils/meta.ts index 709e68c432..87c802c8b4 100644 --- a/services/web/frontend/js/utils/meta.ts +++ b/services/web/frontend/js/utils/meta.ts @@ -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[] diff --git a/services/web/frontend/stories/select.stories.tsx b/services/web/frontend/stories/select.stories.tsx index 6f83e67143..ed4162fee1 100644 --- a/services/web/frontend/stories/select.stories.tsx +++ b/services/web/frontend/stories/select.stories.tsx @@ -29,6 +29,32 @@ export const WithSubtitles = () => { ) } +export const WithSelectedIcon = () => { + return ( + 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, diff --git a/services/web/frontend/stylesheets/app/editor/share.less b/services/web/frontend/stylesheets/app/editor/share.less index d8b35959d4..26b510d1aa 100644 --- a/services/web/frontend/stylesheets/app/editor/share.less +++ b/services/web/frontend/stylesheets/app/editor/share.less @@ -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; + } } } diff --git a/services/web/frontend/stylesheets/components/select.less b/services/web/frontend/stylesheets/components/select.less index 61e6ace288..708005171e 100644 --- a/services/web/frontend/stylesheets/components/select.less +++ b/services/web/frontend/stylesheets/components/select.less @@ -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; } diff --git a/services/web/frontend/stylesheets/components/tags-input.less b/services/web/frontend/stylesheets/components/tags-input.less index 9b41600ac4..3001f67835 100644 --- a/services/web/frontend/stylesheets/components/tags-input.less +++ b/services/web/frontend/stylesheets/components/tags-input.less @@ -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); diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 60ae475130..14038640e1 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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)", "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": "We’ll 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__", "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 log out from your __email1__ account and then log in with __email2__.", "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__ plan as a <1>member of the group subscription <1>__groupName__ administered by <1>__adminEmail__", "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.", + "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 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 can’t add or change your password because your group or organization uses <0>single sign-on (SSO).", @@ -2319,6 +2332,7 @@ "your_password_was_detected": "Your password is on a <0>public list of known compromised passwords. 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__ 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", diff --git a/services/web/test/frontend/components/shared/select.spec.tsx b/services/web/test/frontend/components/shared/select.spec.tsx index 594fd3bb5c..afde06e525 100644 --- a/services/web/test/frontend/components/shared/select.spec.tsx +++ b/services/web/test/frontend/components/shared/select.spec.tsx @@ -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} /> @@ -275,4 +278,55 @@ describe('