diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 94d4aaae98..9f04b5a38d 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -72,7 +72,6 @@ "add_error_assist_annual_to_your_projects": "", "add_error_assist_to_your_projects": "", "add_files": "", - "add_more_collaborators": "", "add_more_editors": "", "add_more_managers": "", "add_more_members": "", @@ -123,7 +122,6 @@ "all_these_experiments_are_available_exclusively": "", "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "", "already_subscribed_try_refreshing_the_page": "", - "also": "", "an_email_has_already_been_sent_to": "", "an_error_occured_while_restoring_project": "", "an_error_occurred_when_verifying_the_coupon_code": "", @@ -866,7 +864,6 @@ "link_accounts_and_add_email": "", "link_institutional_email_get_started": "", "link_sharing": "", - "link_sharing_is_off": "", "link_sharing_is_off_short": "", "link_sharing_is_on": "", "link_to_github": "", @@ -986,7 +983,6 @@ "need_more_than_x_licenses": "", "need_to_add_new_primary_before_remove": "", "need_to_leave": "", - "need_to_upgrade_for_more_collabs": "", "new_compile_domain_notice": "", "new_file": "", "new_folder": "", @@ -1284,7 +1280,6 @@ "remove": "", "remove_access": "", "remove_add_on": "", - "remove_collaborator": "", "remove_from_group": "", "remove_link": "", "remove_manager": "", @@ -1448,7 +1443,6 @@ "setup_another_account_under_a_personal_email_address": "", "share": "", "share_project": "", - "share_with_your_collabs": "", "shared_with_you": "", "sharelatex_beta_program": "", "shortcut_to_open_advanced_reference_search": "", 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 fa2b8b2523..d8e19acb22 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,9 +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 ShareProjectModal from '@/features/share-project-modal/components/restricted-link-sharing/share-project-modal' -import EditorOverLimitModal from '@/features/share-project-modal/components/restricted-link-sharing/editor-over-limit-modal' -import ViewOnlyAccessModal from '@/features/share-project-modal/components/restricted-link-sharing/view-only-access-modal' +import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal' +import EditorOverLimitModal from '@/features/share-project-modal/components/editor-over-limit-modal' +import ViewOnlyAccessModal from '@/features/share-project-modal/components/view-only-access-modal' function EditorNavigationToolbar() { const [showShareModal, setShowShareModal] = useState(false) diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/access-levels-changed.tsx b/services/web/frontend/js/features/share-project-modal/components/access-levels-changed.tsx similarity index 100% rename from services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/access-levels-changed.tsx rename to services/web/frontend/js/features/share-project-modal/components/access-levels-changed.tsx diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx index 8e485d6d35..9ecb7d5583 100644 --- a/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators-upgrade.tsx @@ -1,104 +1,74 @@ -import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useUserContext } from '../../../shared/context/user-context' +import Notification from '@/shared/components/notification' import { upgradePlan } from '@/main/account-upgrade' -import StartFreeTrialButton from '../../../shared/components/start-free-trial-button' -import Icon from '../../../shared/components/icon' -import { useFeatureFlag } from '../../../shared/context/split-test-context' -import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' -import MaterialIcon from '@/shared/components/material-icon' +import { linkSharingEnforcementDate } from '../utils/link-sharing' +import { useProjectContext } from '@/shared/context/project-context' +import { useUserContext } from '@/shared/context/user-context' +import { sendMB } from '@/infrastructure/event-tracking' +import StartFreeTrialButton from '@/shared/components/start-free-trial-button' import OLButton from '@/features/ui/components/ol/ol-button' export default function AddCollaboratorsUpgrade() { const { t } = useTranslation() + const { features } = useProjectContext() const user = useUserContext() - const [startedFreeTrial, setStartedFreeTrial] = useState(false) - const hasNewPaywallCta = useFeatureFlag('paywall-cta') - return (
-

- {t('need_to_upgrade_for_more_collabs')}. {t('also')}: -

- -

- {user.allowedFreeTrial ? ( - setStartedFreeTrial(true)} - source="project-sharing" - > - {hasNewPaywallCta - ? t('add_more_collaborators') - : t('start_free_trial')} - - ) : ( - { - upgradePlan('project-sharing') - setStartedFreeTrial(true) - }} - > - {t('upgrade')} - - )} -

- {startedFreeTrial && ( -

{t('refresh_page_after_starting_free_trial')}

- )} + + {t('your_plan_is_limited_to_n_editors', { + count: features.collaborators, + })}{' '} + {t('from_enforcement_date', { + enforcementDate: linkSharingEnforcementDate, + })} +

+ } + action={ +
+ {user.allowedFreeTrial ? ( + + {t('upgrade')} + + ) : ( + { + upgradePlan('project-sharing') + }} + > + {t('upgrade')} + + )} + { + sendMB('paywall-info-click', { + 'paywall-type': 'project-sharing', + content: 'blog', + variant: 'exceeds', + }) + }} + > + {t('read_more')} + +
+ } + />
) } diff --git a/services/web/frontend/js/features/share-project-modal/components/add-collaborators.jsx b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.jsx index 3a109b335d..98714bb487 100644 --- a/services/web/frontend/js/features/share-project-modal/components/add-collaborators.jsx +++ b/services/web/frontend/js/features/share-project-modal/components/add-collaborators.jsx @@ -1,20 +1,22 @@ -import { useState, useMemo, useCallback } from 'react' +import { useEffect, useState, useMemo, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { useMultipleSelection } from 'downshift' import { useShareProjectContext } from './share-project-modal' import SelectCollaborators from './select-collaborators' import { resendInvite, sendInvite } from '../utils/api' import { useUserContacts } from '../hooks/use-user-contacts' -import useIsMounted from '../../../shared/hooks/use-is-mounted' -import { useProjectContext } from '../../../shared/context/project-context' -import { sendMB } from '../../../infrastructure/event-tracking' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectContext } from '@/shared/context/project-context' +import { sendMB } from '@/infrastructure/event-tracking' import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer' +import PropTypes from 'prop-types' import OLForm from '@/features/ui/components/ol/ol-form' import OLFormGroup from '@/features/ui/components/ol/ol-form-group' import OLFormSelect from '@/features/ui/components/ol/ol-form-select' import OLButton from '@/features/ui/components/ol/ol-button' +import getMeta from '@/utils/meta' -export default function AddCollaborators() { +export default function AddCollaborators({ readOnly }) { const [privileges, setPrivileges] = useState('readAndWrite') const isMounted = useIsMounted() @@ -49,6 +51,12 @@ export default function AddCollaborators() { const { reset, selectedItems } = multipleSelectionProps + useEffect(() => { + if (readOnly && privileges === 'readAndWrite') { + setPrivileges('readOnly') + } + }, [privileges, readOnly]) + const handleSubmit = useCallback(async () => { if (!selectedItems.length) { return @@ -142,12 +150,12 @@ export default function AddCollaborators() { ]) return ( - + @@ -162,8 +170,13 @@ export default function AddCollaborators() { bsSize: 'sm', }} > - - + + + {getMeta('ol-isReviewerRoleEnabled') && ( + + )}    - {t('share')} + {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/collaborators-limit-upgrade.tsx similarity index 100% rename from services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/collaborators-limit-upgrade.tsx rename to services/web/frontend/js/features/share-project-modal/components/collaborators-limit-upgrade.tsx diff --git a/services/web/frontend/js/features/share-project-modal/components/edit-member.jsx b/services/web/frontend/js/features/share-project-modal/components/edit-member.jsx deleted file mode 100644 index bffd42dc87..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/edit-member.jsx +++ /dev/null @@ -1,203 +0,0 @@ -import { useState, useEffect } 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 Icon from '../../../shared/components/icon' -import { useProjectContext } from '../../../shared/context/project-context' -import { sendMB } from '../../../infrastructure/event-tracking' -import OLFormGroup from '@/features/ui/components/ol/ol-form-group' -import OLCol from '@/features/ui/components/ol/ol-col' -import OLFormSelect from '@/features/ui/components/ol/ol-form-select' -import OLTooltip from '@/features/ui/components/ol/ol-tooltip' -import OLButton from '@/features/ui/components/ol/ol-button' -import MaterialIcon from '@/shared/components/material-icon' -import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' -import { bsVersion } from '@/features/utils/bootstrap-5' - -export default function EditMember({ member }) { - const [privileges, setPrivileges] = useState(member.privileges) - const [confirmingOwnershipTransfer, setConfirmingOwnershipTransfer] = - useState(false) - - // 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 } = useProjectContext() - - function handleSubmit(event) { - event.preventDefault() - - if (privileges === 'owner') { - setConfirmingOwnershipTransfer(true) - } else { - monitorRequest(() => - updateMember(projectId, member, { - privilegeLevel: privileges, - }) - ).then(() => { - updateProject({ - members: members.map(item => - item._id === member._id ? { ...item, privileges } : item - ), - }) - }) - } - } - - if (confirmingOwnershipTransfer) { - return ( - setConfirmingOwnershipTransfer(false)} - /> - ) - } - - return ( -
- - - {member.email} - - - - setPrivileges(event.target.value)} - /> - - - - {privileges === member.privileges ? ( - - ) : ( - setPrivileges(member.privileges)} - /> - )} - - -
- ) -} -EditMember.propTypes = { - member: PropTypes.shape({ - _id: PropTypes.string.isRequired, - email: PropTypes.string.isRequired, - privileges: PropTypes.string.isRequired, - }), -} - -function SelectPrivilege({ value, handleChange }) { - const { t } = useTranslation() - - return ( - - - - - - ) -} - -SelectPrivilege.propTypes = { - value: PropTypes.string.isRequired, - handleChange: PropTypes.func.isRequired, -} - -function RemoveMemberAction({ member }) { - const { t } = useTranslation() - const { updateProject, monitorRequest } = useShareProjectContext() - const { _id: projectId, members, invites } = useProjectContext() - - function handleClick(event) { - event.preventDefault() - - 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, - }) - } - ) - } - - return ( -
- - - } - bs5={} - /> - - -
- ) -} - -RemoveMemberAction.propTypes = { - member: PropTypes.shape({ - _id: PropTypes.string.isRequired, - email: PropTypes.string.isRequired, - privileges: PropTypes.string.isRequired, - }), -} - -function ChangePrivilegesActions({ handleReset }) { - const { t } = useTranslation() - - return ( -
- - {t('change_or_cancel-change')} - -
- {t('change_or_cancel-or')} -   - - {t('change_or_cancel-cancel')} - -
-
- ) -} - -ChangePrivilegesActions.propTypes = { - handleReset: PropTypes.func.isRequired, -} 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/edit-member.tsx similarity index 97% rename from services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/edit-member.tsx rename to services/web/frontend/js/features/share-project-modal/components/edit-member.tsx index fad0781b36..26d5409b6e 100644 --- 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/edit-member.tsx @@ -3,14 +3,14 @@ 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 { removeMemberFromProject, updateMember } from '../utils/api' 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' -import { linkSharingEnforcementDate } from '../../utils/link-sharing' +import { linkSharingEnforcementDate } from '../utils/link-sharing' import OLButton from '@/features/ui/components/ol/ol-button' import OLFormGroup from '@/features/ui/components/ol/ol-form-group' import OLCol from '@/features/ui/components/ol/ol-col' @@ -128,7 +128,9 @@ export default function EditMember({ id="share-project-form" onSubmit={e => { e.preventDefault() - commitPrivilegeChange(privileges) + if (privilegeChangePending) { + commitPrivilegeChange(privileges) + } }} > - +
{invite.email}
-
{t('invite_not_accepted')} .  @@ -30,12 +29,12 @@ export default function Invite({ invite, isProjectOwner }) {
- + {isProjectOwner && ( - + )} diff --git a/services/web/frontend/js/features/share-project-modal/components/link-sharing.jsx b/services/web/frontend/js/features/share-project-modal/components/link-sharing.jsx index 11b5e4839f..fd306b15ae 100644 --- a/services/web/frontend/js/features/share-project-modal/components/link-sharing.jsx +++ b/services/web/frontend/js/features/share-project-modal/components/link-sharing.jsx @@ -1,16 +1,16 @@ import { useCallback, useState, useEffect } from 'react' import PropTypes from 'prop-types' import { useTranslation } from 'react-i18next' -import Icon from '../../../shared/components/icon' +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 { useProjectContext } from '@/shared/context/project-context' import * as eventTracking from '../../../infrastructure/event-tracking' -import { useUserContext } from '../../../shared/context/user-context' +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 useAbortController from '@/shared/hooks/use-abort-controller' import { debugConsole } from '@/utils/debugging' import getMeta from '@/utils/meta' import OLRow from '@/features/ui/components/ol/ol-row' @@ -22,6 +22,7 @@ import MaterialIcon from '@/shared/components/material-icon' export default function LinkSharing() { const [inflight, setInflight] = useState(false) + const [showLinks, setShowLinks] = useState(true) const { monitorRequest } = useShareProjectContext() @@ -57,6 +58,7 @@ export default function LinkSharing() { setAccessLevel={setAccessLevel} inflight={inflight} projectId={projectId} + setShowLinks={setShowLinks} /> ) @@ -66,6 +68,8 @@ export default function LinkSharing() { ) @@ -85,12 +89,12 @@ export default function LinkSharing() { } } -function PrivateSharing({ setAccessLevel, inflight, projectId }) { +function PrivateSharing({ setAccessLevel, inflight, projectId, setShowLinks }) { const { t } = useTranslation() return ( - {t('link_sharing_is_off')} + {t('link_sharing_is_off_short')}    { setAccessLevel('tokenBased') eventTracking.sendMB('link-sharing-click', { projectId }) + setShowLinks(true) }} disabled={inflight} > @@ -114,9 +119,15 @@ PrivateSharing.propTypes = { setAccessLevel: PropTypes.func.isRequired, inflight: PropTypes.bool, projectId: PropTypes.string, + setShowLinks: PropTypes.func.isRequired, } -function TokenBasedSharing({ setAccessLevel, inflight }) { +function TokenBasedSharing({ + setAccessLevel, + inflight, + setShowLinks, + showLinks, +}) { const { t } = useTranslation() const { _id: projectId } = useProjectContext() @@ -145,27 +156,43 @@ function TokenBasedSharing({ setAccessLevel, inflight }) {    - - -
- {t('anyone_with_link_can_edit')} - setShowLinks(!showLinks)} + > + } + bs5={ + + } /> -
-
- {t('anyone_with_link_can_view')} - -
+
+ {showLinks && ( + +
+ {t('anyone_with_link_can_edit')} + +
+
+ {t('anyone_with_link_can_view')} + +
+
+ )}
) } @@ -173,6 +200,8 @@ function TokenBasedSharing({ setAccessLevel, inflight }) { TokenBasedSharing.propTypes = { setAccessLevel: PropTypes.func.isRequired, inflight: PropTypes.bool, + setShowLinks: PropTypes.func.isRequired, + showLinks: PropTypes.bool, } function LegacySharing({ accessLevel, setAccessLevel, inflight }) { diff --git a/services/web/frontend/js/features/share-project-modal/components/owner-info.jsx b/services/web/frontend/js/features/share-project-modal/components/owner-info.jsx index bb0e8e99c6..e8dea7e6cd 100644 --- a/services/web/frontend/js/features/share-project-modal/components/owner-info.jsx +++ b/services/web/frontend/js/features/share-project-modal/components/owner-info.jsx @@ -1,7 +1,10 @@ -import { useProjectContext } from '../../../shared/context/project-context' +import { useProjectContext } from '@/shared/context/project-context' import { useTranslation } from 'react-i18next' +import Icon from '@/shared/components/icon' import OLRow from '@/features/ui/components/ol/ol-row' import OLCol from '@/features/ui/components/ol/ol-col' +import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' +import MaterialIcon from '@/shared/components/material-icon' export default function OwnerInfo() { const { t } = useTranslation() @@ -9,8 +12,16 @@ export default function OwnerInfo() { return ( - {owner?.email} - + +
+ } + bs5={} + /> +
{owner?.email}
+
+
+ {t('owner')}
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 deleted file mode 100644 index 6619b41a5d..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators-upgrade.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useTranslation } from 'react-i18next' -import Notification from '@/shared/components/notification' -import { upgradePlan } from '@/main/account-upgrade' -import { linkSharingEnforcementDate } from '../../utils/link-sharing' -import { useProjectContext } from '@/shared/context/project-context' -import { useUserContext } from '@/shared/context/user-context' -import { sendMB } from '@/infrastructure/event-tracking' -import StartFreeTrialButton from '@/shared/components/start-free-trial-button' -import OLButton from '@/features/ui/components/ol/ol-button' - -export default function AddCollaboratorsUpgrade() { - const { t } = useTranslation() - const { features } = useProjectContext() - const user = useUserContext() - - return ( -
- - {t('your_plan_is_limited_to_n_editors', { - count: features.collaborators, - })}{' '} - {t('from_enforcement_date', { - enforcementDate: linkSharingEnforcementDate, - })} -

- } - action={ -
- {user.allowedFreeTrial ? ( - - {t('upgrade')} - - ) : ( - { - upgradePlan('project-sharing') - }} - > - {t('upgrade')} - - )} - { - sendMB('paywall-info-click', { - 'paywall-type': 'project-sharing', - content: 'blog', - variant: 'exceeds', - }) - }} - > - {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 deleted file mode 100644 index eea0e7af94..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/add-collaborators.jsx +++ /dev/null @@ -1,197 +0,0 @@ -import { useEffect, useState, useMemo, useCallback } from 'react' -import { useTranslation } from 'react-i18next' -import { useMultipleSelection } from 'downshift' -import { useShareProjectContext } from './share-project-modal' -import SelectCollaborators from './select-collaborators' -import { resendInvite, sendInvite } from '../../utils/api' -import { useUserContacts } from '../../hooks/use-user-contacts' -import useIsMounted from '@/shared/hooks/use-is-mounted' -import { useProjectContext } from '@/shared/context/project-context' -import { sendMB } from '@/infrastructure/event-tracking' -import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer' -import PropTypes from 'prop-types' -import OLForm from '@/features/ui/components/ol/ol-form' -import OLFormGroup from '@/features/ui/components/ol/ol-form-group' -import OLFormSelect from '@/features/ui/components/ol/ol-form-select' -import OLButton from '@/features/ui/components/ol/ol-button' -import getMeta from '@/utils/meta' - -export default function AddCollaborators({ readOnly }) { - const [privileges, setPrivileges] = useState('readAndWrite') - - const isMounted = useIsMounted() - - const { data: contacts } = useUserContacts() - - const { t } = useTranslation() - - const { updateProject, setInFlight, setError } = useShareProjectContext() - - const { _id: projectId, members, invites } = useProjectContext() - - const currentMemberEmails = useMemo( - () => (members || []).map(member => member.email).sort(), - [members] - ) - - const nonMemberContacts = useMemo(() => { - if (!contacts) { - return null - } - - return contacts.filter( - contact => !currentMemberEmails.includes(contact.email) - ) - }, [contacts, currentMemberEmails]) - - const multipleSelectionProps = useMultipleSelection({ - initialActiveIndex: 0, - initialSelectedItems: [], - }) - - const { reset, selectedItems } = multipleSelectionProps - - useEffect(() => { - if (readOnly && privileges === 'readAndWrite') { - setPrivileges('readOnly') - } - }, [privileges, readOnly]) - - const handleSubmit = useCallback(async () => { - if (!selectedItems.length) { - return - } - - // reset the selected items - reset() - - setError(undefined) - setInFlight(true) - - for (const contact of selectedItems) { - // unmounting means can't add any more collaborators - if (!isMounted.current) { - break - } - - const email = contact.type === 'user' ? contact.email : contact.display - const normalisedEmail = email.toLowerCase() - - if (currentMemberEmails.includes(normalisedEmail)) { - continue - } - - let data - - try { - const invite = (invites || []).find( - invite => invite.email === normalisedEmail - ) - - if (invite) { - data = await resendInvite(projectId, invite) - } else { - data = await sendInvite(projectId, email, privileges) - } - - 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 ( - - - - - - -
- setPrivileges(event.target.value)} - bs3Props={{ - bsSize: 'sm', - }} - > - - - {getMeta('ol-isReviewerRoleEnabled') && ( - - )} - -    - - {t('invite')} - -
-
-
- ) -} - -AddCollaborators.propTypes = { - readOnly: PropTypes.bool, -} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/invite.jsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/invite.jsx deleted file mode 100644 index 14331abd8a..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/invite.jsx +++ /dev/null @@ -1,142 +0,0 @@ -import { useCallback } from 'react' -import PropTypes from 'prop-types' -import { useShareProjectContext } from './share-project-modal' -import Icon from '@/shared/components/icon' -import { useTranslation } from 'react-i18next' -import MemberPrivileges from './member-privileges' -import { resendInvite, revokeInvite } from '../../utils/api' -import { useProjectContext } from '@/shared/context/project-context' -import { sendMB } from '@/infrastructure/event-tracking' -import OLRow from '@/features/ui/components/ol/ol-row' -import OLCol from '@/features/ui/components/ol/ol-col' -import OLTooltip from '@/features/ui/components/ol/ol-tooltip' -import OLButton from '@/features/ui/components/ol/ol-button' -import MaterialIcon from '@/shared/components/material-icon' -import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' -import { bsVersion } from '@/features/utils/bootstrap-5' -import classnames from 'classnames' - -export default function Invite({ invite, isProjectOwner }) { - const { t } = useTranslation() - return ( - - -
{invite.email}
-
- {t('invite_not_accepted')} - .  - {isProjectOwner && } -
-
- - - - - - {isProjectOwner && ( - - - - )} -
- ) -} - -Invite.propTypes = { - invite: PropTypes.object.isRequired, - isProjectOwner: PropTypes.bool.isRequired, -} - -function ResendInvite({ invite }) { - const { t } = useTranslation() - const { monitorRequest, setError, inFlight } = useShareProjectContext() - const { _id: projectId } = useProjectContext() - - // const buttonRef = useRef(null) - // - const handleClick = useCallback( - () => - monitorRequest(() => resendInvite(projectId, invite)) - .catch(error => { - if (error?.response?.status === 404) { - setError('invite_expired') - } - if (error?.response?.status === 429) { - setError('invite_resend_limit_hit') - } - }) - .finally(() => { - // NOTE: disabled as react-bootstrap v0.33.1 isn't forwarding the ref to the `button` - // if (buttonRef.current) { - // buttonRef.current.blur() - // } - document.activeElement.blur() - }), - [invite, monitorRequest, projectId, setError] - ) - - return ( - - {t('resend')} - - ) -} - -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 ( - - - } - bs5={} - /> - - - ) -} - -RevokeInvite.propTypes = { - invite: PropTypes.object.isRequired, -} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/link-sharing.jsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/link-sharing.jsx deleted file mode 100644 index 816ff96a3a..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/link-sharing.jsx +++ /dev/null @@ -1,325 +0,0 @@ -import { useCallback, useState, useEffect } from 'react' -import PropTypes from 'prop-types' -import { useTranslation } from 'react-i18next' -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' -import getMeta from '@/utils/meta' -import OLRow from '@/features/ui/components/ol/ol-row' -import OLCol from '@/features/ui/components/ol/ol-col' -import OLButton from '@/features/ui/components/ol/ol-button' -import OLTooltip from '@/features/ui/components/ol/ol-tooltip' -import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' -import MaterialIcon from '@/shared/components/material-icon' - -export default function LinkSharing() { - const [inflight, setInflight] = useState(false) - const [showLinks, setShowLinks] = useState(true) - - const { monitorRequest } = useShareProjectContext() - - const { _id: projectId, publicAccessLevel } = useProjectContext() - - // set the access level of a project - const setAccessLevel = useCallback( - newPublicAccessLevel => { - setInflight(true) - sendMB('link-sharing-click-off', { - project_id: projectId, - }) - monitorRequest(() => - setProjectAccessLevel(projectId, newPublicAccessLevel) - ) - .then(() => { - // NOTE: not calling `updateProject` here as it receives data via - // project:publicAccessLevel:changed over the websocket connection - // TODO: eventTracking.sendMB('project-make-token-based') when newPublicAccessLevel is 'tokenBased' - }) - .finally(() => { - setInflight(false) - }) - }, - [monitorRequest, projectId] - ) - - switch (publicAccessLevel) { - // Private (with token-access available) - case 'private': - return ( - - ) - - // Token-based access - case 'tokenBased': - return ( - - ) - - // Legacy public-access - case 'readAndWrite': - case 'readOnly': - return ( - - ) - - default: - return null - } -} - -function PrivateSharing({ setAccessLevel, inflight, projectId, setShowLinks }) { - const { t } = useTranslation() - return ( - - - {t('link_sharing_is_off_short')} -    - { - setAccessLevel('tokenBased') - eventTracking.sendMB('link-sharing-click', { projectId }) - setShowLinks(true) - }} - disabled={inflight} - > - {t('turn_on_link_sharing')} - -    - - - - ) -} - -PrivateSharing.propTypes = { - setAccessLevel: PropTypes.func.isRequired, - inflight: PropTypes.bool, - projectId: PropTypes.string, - setShowLinks: PropTypes.func.isRequired, -} - -function TokenBasedSharing({ - setAccessLevel, - inflight, - setShowLinks, - showLinks, -}) { - const { t } = useTranslation() - const { _id: projectId } = useProjectContext() - - const [tokens, setTokens] = useState(null) - - const { signal } = useAbortController() - - useEffect(() => { - getJSON(`/project/${projectId}/tokens`, { signal }) - .then(data => setTokens(data)) - .catch(debugConsole.error) - }, [projectId, signal]) - - return ( - - - {t('link_sharing_is_on')} -    - setAccessLevel('private')} - disabled={inflight} - > - {t('turn_off_link_sharing')} - -    - - setShowLinks(!showLinks)} - > - } - bs5={ - - } - /> - - - {showLinks && ( - -
- {t('anyone_with_link_can_edit')} - -
-
- {t('anyone_with_link_can_view')} - -
-
- )} -
- ) -} - -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 ( - - - - {accessLevel === 'readAndWrite' && t('this_project_is_public')} - {accessLevel === 'readOnly' && t('this_project_is_public_read_only')} - -    - setAccessLevel('private')} - disabled={inflight} - > - {t('make_private')} - -    - - - - ) -} - -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 ( - - -
- {t('anyone_with_link_can_view')} - -
-
-
- ) -} - -function AccessToken({ token, tokenHashPrefix, path, tooltipId }) { - const { t } = useTranslation() - const { isAdmin } = useUserContext() - - if (!token) { - return ( -
-        {t('loading')}…
-      
- ) - } - - let origin = window.location.origin - if (isAdmin) { - origin = getMeta('ol-ExposedSettings').siteUrl - } - const link = `${origin}${path}${token}${ - tokenHashPrefix ? `#${tokenHashPrefix}` : '' - }` - - return ( -
- {link} - -
- ) -} - -AccessToken.propTypes = { - token: PropTypes.string, - tokenHashPrefix: PropTypes.string, - tooltipId: PropTypes.string.isRequired, - path: PropTypes.string.isRequired, -} - -function LinkSharingInfo() { - const { t } = useTranslation() - - return ( - - - } - bs5={} - /> - - - ) -} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/member-privileges.jsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/member-privileges.jsx deleted file mode 100644 index a968bfc5fd..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/member-privileges.jsx +++ /dev/null @@ -1,23 +0,0 @@ -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') - - case 'review': - return t('can_review') - - default: - return null - } -} -MemberPrivileges.propTypes = { - privileges: PropTypes.string.isRequired, -} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/owner-info.jsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/owner-info.jsx deleted file mode 100644 index e8dea7e6cd..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/owner-info.jsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useProjectContext } from '@/shared/context/project-context' -import { useTranslation } from 'react-i18next' -import Icon from '@/shared/components/icon' -import OLRow from '@/features/ui/components/ol/ol-row' -import OLCol from '@/features/ui/components/ol/ol-col' -import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' -import MaterialIcon from '@/shared/components/material-icon' - -export default function OwnerInfo() { - const { t } = useTranslation() - const { owner } = useProjectContext() - - return ( - - -
- } - bs5={} - /> -
{owner?.email}
-
-
- - {t('owner')} - -
- ) -} diff --git a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/select-collaborators.jsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/select-collaborators.jsx deleted file mode 100644 index 3ccd72d091..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/select-collaborators.jsx +++ /dev/null @@ -1,419 +0,0 @@ -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 Icon from '@/shared/components/icon' -import MaterialIcon from '@/shared/components/material-icon' -import Tag from '@/features/ui/components/bootstrap-5/tag' -import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu' -import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' -import { bsVersion } from '@/features/utils/bootstrap-5' -import { Spinner } from 'react-bootstrap-5' - -// Unicode characters in these Unicode groups: -// "General Punctuation — Spaces" -// "General Punctuation — Format character" (including zero-width spaces) -const matchAllSpaces = - /[\u061C\u2000-\u200F\u202A-\u202E\u2060\u2066-\u2069\u2028\u2029\u202F]/g - -export default function SelectCollaborators({ - loading, - options, - placeholder, - multipleSelectionProps, -}) { - const { t } = useTranslation() - const { - getSelectedItemProps, - getDropdownProps, - addSelectedItem, - removeSelectedItem, - selectedItems, - } = multipleSelectionProps - - const [inputValue, setInputValue] = useState('') - - const selectedEmails = useMemo( - () => selectedItems.map(item => item.email), - [selectedItems] - ) - - const unselectedOptions = useMemo( - () => options.filter(option => !selectedEmails.includes(option.email)), - [options, selectedEmails] - ) - - const filteredOptions = useMemo(() => { - if (inputValue === '') { - return unselectedOptions - } - - return matchSorter(unselectedOptions, inputValue, { - keys: ['name', 'email'], - threshold: matchSorter.rankings.CONTAINS, - baseSort: (a, b) => { - // Prefer server-side sorting for ties in the match ranking. - return a.index - b.index > 0 ? 1 : -1 - }, - }) - }, [unselectedOptions, inputValue]) - - const inputRef = useRef(null) - - const focusInput = useCallback(() => { - if (inputRef.current) { - window.setTimeout(() => { - inputRef.current.focus() - }, 10) - } - }, [inputRef]) - - const isValidInput = useMemo(() => { - if (inputValue.includes('@')) { - for (const selectedItem of selectedItems) { - if (selectedItem.email === inputValue) { - return false - } - } - } - - return true - }, [inputValue, selectedItems]) - - function stateReducer(state, actionAndChanges) { - const { type, changes } = actionAndChanges - // force selected item to be null so that adding, removing, then re-adding the same collaborator is recognised as a selection change - if (type === useCombobox.stateChangeTypes.InputChange) { - return { ...changes, selectedItem: null } - } - return changes - } - - const { - isOpen, - getLabelProps, - getMenuProps, - getInputProps, - getComboboxProps, - highlightedIndex, - getItemProps, - reset, - } = useCombobox({ - inputValue, - defaultHighlightedIndex: 0, - items: filteredOptions, - itemToString: item => item && item.name, - stateReducer, - onStateChange: ({ inputValue, type, selectedItem }) => { - switch (type) { - // add a selected item on Enter (keypress), click or blur - case useCombobox.stateChangeTypes.InputKeyDownEnter: - case useCombobox.stateChangeTypes.ItemClick: - case useCombobox.stateChangeTypes.InputBlur: - if (selectedItem) { - setInputValue('') - addSelectedItem(selectedItem) - } - break - } - }, - }) - - const addNewItem = useCallback( - (_email, focus = true) => { - const email = _email.replace(matchAllSpaces, '') - - if ( - isValidInput && - email.includes('@') && - !selectedEmails.includes(email) - ) { - addSelectedItem({ - email, - display: email, - type: 'user', - }) - setInputValue('') - reset() - if (focus) { - focusInput() - } - return true - } - }, - [addSelectedItem, selectedEmails, isValidInput, focusInput, reset] - ) - - // close and reset the menu when there are no matching items - useEffect(() => { - if (isOpen && filteredOptions.length === 0) { - reset() - } - }, [reset, isOpen, filteredOptions.length]) - - return ( -
- {/* eslint-disable-next-line jsx-a11y/label-has-for */} - - -
- {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */} -
- {selectedItems.map((selectedItem, index) => ( - - ))} - - { - 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) - } - } - } - }, - }) - )} - /> -
- -
-
    - {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, -} - -function Option({ selected, item, getItemProps, index }) { - return ( -
  • - - -   - {item.display} - - } - bs5={ - - {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} - - - } - bs5={ - } - bs5={} - /> - } - closeBtnProps={{ - onClick: handleClick, - }} - {...getSelectedItemProps({ selectedItem, index })} - > - {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.jsx b/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites.jsx deleted file mode 100644 index f27d42f404..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/send-invites.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import AddCollaborators from './add-collaborators' -import AddCollaboratorsUpgrade from './add-collaborators-upgrade' -import CollaboratorsLimitUpgrade from './collaborators-limit-upgrade' -import AccessLevelsChanged from './access-levels-changed' -import PropTypes from 'prop-types' -import OLRow from '@/features/ui/components/ol/ol-row' - -export default function SendInvites({ - canAddCollaborators, - hasExceededCollaboratorLimit, - haveAnyEditorsBeenDowngraded, - somePendingEditorsResolved, -}) { - return ( - - {hasExceededCollaboratorLimit && !haveAnyEditorsBeenDowngraded && ( - - )} - - {haveAnyEditorsBeenDowngraded && ( - - )} - - {!canAddCollaborators && - !hasExceededCollaboratorLimit && - !haveAnyEditorsBeenDowngraded && } - - - ) -} - -SendInvites.propTypes = { - canAddCollaborators: PropTypes.bool, - hasExceededCollaboratorLimit: PropTypes.bool, - haveAnyEditorsBeenDowngraded: PropTypes.bool, - somePendingEditorsResolved: 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 deleted file mode 100644 index aabb6f3628..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-modal-body.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import EditMember from './edit-member' -import LinkSharing from './link-sharing' -import Invite from './invite' -import SendInvites from './send-invites' -import ViewMember from './view-member' -import OwnerInfo from './owner-info' -import SendInvitesNotice from './send-invites-notice' -import { useEditorContext } from '@/shared/context/editor-context' -import { useProjectContext } from '@/shared/context/project-context' -import { useMemo } from 'react' -import RecaptchaConditions from '@/shared/components/recaptcha-conditions' -import getMeta from '@/utils/meta' - -export default function ShareModalBody() { - const { members, invites, features } = useProjectContext() - const { isProjectOwner } = useEditorContext() - - // whether the project has not reached the collaborator limit - const canAddCollaborators = useMemo(() => { - if (!isProjectOwner || !features) { - return false - } - - if (features.collaborators === -1) { - // infinite collaborators - return true - } - - const editorInvites = invites.filter( - invite => invite.privileges === 'readAndWrite' - ).length - - return ( - members.filter(member => member.privileges === 'readAndWrite').length + - editorInvites < - (features.collaborators ?? 1) - ) - }, [members, invites, features, isProjectOwner]) - - // determine if some but not all pending editors' permissions have been resolved, - // for moving between warning and info notification states etc. - const somePendingEditorsResolved = useMemo(() => { - return ( - members.some(member => member.privileges === 'readAndWrite') && - members.some(member => member.pendingEditor) - ) - }, [members]) - - const haveAnyEditorsBeenDowngraded = useMemo(() => { - if (!isProjectOwner || !features) { - return false - } - - if (features.collaborators === -1) { - return false - } - return members.some(member => member.pendingEditor) - }, [features, isProjectOwner, members]) - - 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]) - - const sortedMembers = useMemo(() => { - return [ - ...members.filter(member => member.privileges === 'readAndWrite'), - ...members.filter(member => member.pendingEditor), - ...members.filter( - member => !member.pendingEditor && member.privileges !== 'readAndWrite' - ), - ] - }, [members]) - - return ( - <> - {isProjectOwner ? ( - - ) : ( - - )} - {isProjectOwner && } - - - - {sortedMembers.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 deleted file mode 100644 index 7a9656e61c..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal-content.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { useTranslation } from 'react-i18next' -import Icon from '@/shared/components/icon' -import { useEditorContext } from '@/shared/context/editor-context' -import { lazy, Suspense } from 'react' -import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner' -import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer' -import OLModal, { - OLModalBody, - OLModalFooter, - OLModalHeader, - OLModalTitle, -} from '@/features/ui/components/ol/ol-modal' -import OLNotification from '@/features/ui/components/ol/ol-notification' -import OLButton from '@/features/ui/components/ol/ol-button' -import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' -import { bsVersion } from '@/features/utils/bootstrap-5' -import { Spinner } from 'react-bootstrap-5' - -const ReadOnlyTokenLink = lazy(() => - import('./link-sharing').then(({ ReadOnlyTokenLink }) => ({ - // re-export as default -- lazy can only handle default exports. - default: ReadOnlyTokenLink, - })) -) - -const ShareModalBody = lazy(() => import('./share-modal-body')) - -type ShareProjectModalContentProps = { - cancel: () => void - show: boolean - animation: boolean - inFlight: boolean - error: string | undefined -} - -export default function ShareProjectModalContent({ - show, - cancel, - animation, - inFlight, - error, -}: ShareProjectModalContentProps) { - const { t } = useTranslation() - - const { isRestrictedTokenMember } = useEditorContext() - - return ( - - - {t('share_project')} - - - -
    - }> - {isRestrictedTokenMember ? ( - - ) : ( - - )} - - {error && ( - } - className="mb-0 mt-3" - /> - )} -
    -
    - - -
    - {inFlight && ( - } - bs5={ -
    - - - {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')} - - case 'invite_expired': - return <>{t('invite_expired')} - - case 'invite_resend_limit_hit': - return <>{t('invite_resend_limit_hit')} - - default: - return <>{t('generic_something_went_wrong')} - } -} 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 deleted file mode 100644 index ca2756fa14..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/share-project-modal.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import React, { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react' -import ShareProjectModalContent from './share-project-modal-content' -import { useProjectContext } from '@/shared/context/project-context' -import { useSplitTestContext } from '@/shared/context/split-test-context' -import { sendMB } from '@/infrastructure/event-tracking' -import { ProjectContextUpdateValue } from '@/shared/context/types/project-context' -import { useEditorContext } from '@/shared/context/editor-context' -import customLocalStorage from '@/infrastructure/local-storage' - -type ShareProjectContextValue = { - updateProject: (project: ProjectContextUpdateValue) => void - monitorRequest: >(request: () => T) => T - inFlight: boolean - setInFlight: React.Dispatch< - React.SetStateAction - > - error: string | undefined - setError: React.Dispatch< - React.SetStateAction - > -} - -const SHOW_MODAL_COOLDOWN_PERIOD = 24 * 60 * 60 * 1000 // 24 hours - -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 - handleOpen: () => void - animation?: boolean -} - -const ShareProjectModal = React.memo(function ShareProjectModal({ - handleHide, - show, - handleOpen, - animation = true, -}: ShareProjectModalProps) { - const [inFlight, setInFlight] = - useState(false) - const [error, setError] = useState() - - const project = useProjectContext() - const { isProjectOwner } = useEditorContext() - - const { splitTestVariants } = useSplitTestContext() - - // show the new share modal if project owner - // is over collaborator limit or has pending editors (once every 24 hours) - useEffect(() => { - const hasExceededCollaboratorLimit = () => { - if (!isProjectOwner || !project.features) { - return false - } - - if (project.features.collaborators === -1) { - return false - } - return ( - project.members.filter(member => member.privileges === 'readAndWrite') - .length > (project.features.collaborators ?? 1) || - project.members.some(member => member.pendingEditor) - ) - } - - if (hasExceededCollaboratorLimit()) { - const localStorageKey = `last-shown-share-modal.${project._id}` - const lastShownShareModalTime = - customLocalStorage.getItem(localStorageKey) - if ( - !lastShownShareModalTime || - lastShownShareModalTime + SHOW_MODAL_COOLDOWN_PERIOD < Date.now() - ) { - handleOpen() - customLocalStorage.setItem(localStorageKey, Date.now()) - } - } - }, [project, isProjectOwner, handleOpen]) - - // send tracking event when the modal is opened - useEffect(() => { - if (show) { - sendMB('share-modal-opened', { - splitTestVariant: splitTestVariants['null-test-share-modal'], - project_id: project._id, - }) - } - }, [splitTestVariants, project._id, show]) - - // reset error when the modal is opened - useEffect(() => { - if (show) { - setError(undefined) - } - }, [show]) - - // close the modal if not in flight - const cancel = useCallback(() => { - if (!inFlight) { - handleHide() - } - }, [handleHide, inFlight]) - - // update `error` and `inFlight` while sending a request - const monitorRequest = useCallback(request => { - setError(undefined) - setInFlight(true) - - const promise = request() - - promise.catch((error: { data?: Record }) => { - 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 deleted file mode 100644 index 88a14ac610..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/transfer-ownership-modal.jsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useState } from 'react' -import { Trans, useTranslation } from 'react-i18next' -import PropTypes from 'prop-types' -import Icon from '@/shared/components/icon' -import { transferProjectOwnership } from '../../utils/api' -import { useProjectContext } from '@/shared/context/project-context' -import { useLocation } from '@/shared/hooks/use-location' -import OLModal, { - OLModalBody, - OLModalFooter, - OLModalHeader, - OLModalTitle, -} from '@/features/ui/components/ol/ol-modal' -import OLNotification from '@/features/ui/components/ol/ol-notification' -import OLButton from '@/features/ui/components/ol/ol-button' -import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' -import { bsVersion } from '@/features/utils/bootstrap-5' -import { Spinner } from 'react-bootstrap-5' - -export default function TransferOwnershipModal({ member, cancel }) { - const { t } = useTranslation() - - const [inflight, setInflight] = useState(false) - const [error, setError] = useState(false) - const location = useLocation() - - const { _id: projectId, name: projectName } = useProjectContext() - - function confirm() { - setError(false) - setInflight(true) - - transferProjectOwnership(projectId, member) - .then(() => { - location.reload() - }) - .catch(() => { - setError(true) - setInflight(false) - }) - } - - return ( - - - {t('change_project_owner')} - - -

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

    -

    {t('project_ownership_transfer_confirmation_2')}

    - {error && ( - - )} -
    - -
    - {inflight && ( - } - bs5={ -
    - - {t('cancel')} - - - {t('change_owner')} - -
    -
    - ) -} -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 deleted file mode 100644 index cc173bd114..0000000000 --- a/services/web/frontend/js/features/share-project-modal/components/restricted-link-sharing/view-member.jsx +++ /dev/null @@ -1,34 +0,0 @@ -import PropTypes from 'prop-types' -import MemberPrivileges from './member-privileges' -import Icon from '@/shared/components/icon' -import OLRow from '@/features/ui/components/ol/ol-row' -import OLCol from '@/features/ui/components/ol/ol-col' -import MaterialIcon from '@/shared/components/material-icon' -import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher' - -export default function ViewMember({ member }) { - return ( - - -
    - } - bs5={} - /> -
    {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/features/share-project-modal/components/select-collaborators.jsx b/services/web/frontend/js/features/share-project-modal/components/select-collaborators.jsx index b13581894e..3ccd72d091 100644 --- a/services/web/frontend/js/features/share-project-modal/components/select-collaborators.jsx +++ b/services/web/frontend/js/features/share-project-modal/components/select-collaborators.jsx @@ -5,7 +5,7 @@ import { matchSorter } from 'match-sorter' import { useCombobox } from 'downshift' import classnames from 'classnames' -import Icon from '../../../shared/components/icon' +import Icon from '@/shared/components/icon' import MaterialIcon from '@/shared/components/material-icon' import Tag from '@/features/ui/components/bootstrap-5/tag' import { DropdownItem } from '@/features/ui/components/bootstrap-5/dropdown-menu' @@ -155,11 +155,13 @@ export default function SelectCollaborators({ }, [reset, isOpen, filteredOptions.length]) return ( -
    +
    {/* eslint-disable-next-line jsx-a11y/label-has-for */}