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={
+
+ }
+ />
+
+ )
+}
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 (
+
+ )
+}
+
+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 (
+
+ )
+}
+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 (
+