From 952266da94ad3b4686cfd6d551a57fa76bd17bc0 Mon Sep 17 00:00:00 2001 From: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:11:24 +0300 Subject: [PATCH] Merge pull request #33553 from overleaf/ii-share-modal-privileges-2 [web] Hook up project access controls and privileges to sharing link API GitOrigin-RevId: 05407476340cabf1ab56a18008f9f3fa978b9d79 --- .../web/frontend/extracted-translations.json | 1 - .../components/link-sharing.tsx | 2 +- .../components/project-access.tsx | 146 +++++++++++------- .../share-project-modal-content.tsx | 4 +- .../components/share-project-modal.tsx | 61 ++++++-- .../features/share-project-modal/utils/api.ts | 26 ++++ services/web/locales/en.json | 3 +- .../components/share-project-modal.test.tsx | 64 ++++++++ services/web/types/utils.ts | 2 + 9 files changed, 234 insertions(+), 75 deletions(-) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index d0479dfbeb..3cb4acae63 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1331,7 +1331,6 @@ "not_a_student": "", "not_managed": "", "not_now": "", - "not_permitted_by_your_organization": "", "notification": "", "notification_personal_and_group_subscriptions": "", "notification_project_invite_accepted_message": "", diff --git a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx index 3ec1e70a25..5c5a619156 100644 --- a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx @@ -67,7 +67,7 @@ export default function LinkSharing() { } if (isSharingUpdatesEnabled) { - if (projectAccess === 'linkSharing') { + if (projectAccess === 'legacyLinkSharing') { return } return null diff --git a/services/web/frontend/js/features/share-project-modal/components/project-access.tsx b/services/web/frontend/js/features/share-project-modal/components/project-access.tsx index 4be98572ab..148c7d7b7e 100644 --- a/services/web/frontend/js/features/share-project-modal/components/project-access.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/project-access.tsx @@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next' import ShareProjectModalRow from '@/features/share-project-modal/components/share-project-modal-row' import MaterialIcon from '@/shared/components/material-icon' import OLButton from '@/shared/components/ol/ol-button' -import OLTooltip from '@/shared/components/ol/ol-tooltip' import OLDropdownMenuItem from '@/shared/components/ol/ol-dropdown-menu-item' import { Dropdown, @@ -15,20 +14,30 @@ import { import DropdownListItem from '@/shared/components/dropdown/dropdown-list-item' import LinkSharing from '@/features/share-project-modal/components/link-sharing' import { useEditorContext } from '@/shared/context/editor-context' -import { PermissionsLevel } from '@/features/ide-react/types/permissions' +import { useProjectContext } from '@/shared/context/project-context' +import { + SharingLinkData, + SharingLinkPrivileges, + setPublicAccessLevel, + updateSharingLink, +} from '../utils/api' import MemberPrivileges from '@/features/share-project-modal/components/member-privileges' import RemoveSharingLinksModal from '@/features/share-project-modal/components/remove-sharing-links-modal' import { ProjectAccessType, useShareProjectContext, } from '@/features/share-project-modal/components/share-project-modal' +import { ExcludeStrict } from '@ol-types/utils' type ProjectAccessProps = { setIsInvitedPeopleScreen: React.Dispatch> invitedPeopleCount: number } -export type PendingAccessType = Exclude +export type PendingAccessType = ExcludeStrict< + ProjectAccessType, + 'legacyLinkSharing' +> function ProjectAccess({ setIsInvitedPeopleScreen, @@ -38,35 +47,62 @@ function ProjectAccess({ const [pendingAccess, setPendingAccess] = useState( null ) - // TODO set initial state - const [privileges, setPrivileges] = - useState>('readOnly') const { isProjectOwner } = useEditorContext() - // TODO set company name - const companyName = 'XYZ' + // TODO get the group name from backend + const groupName = 'your group' const { monitorRequest, setSuccessActionMessage, projectAccess, setProjectAccess, + sharingLinkData, + setSharingLinkData, } = useShareProjectContext() + const { projectId } = useProjectContext() + + const privileges = sharingLinkData?.privileges const handleAccessChange = (newAccess: PendingAccessType) => { setPendingAccess(null) - monitorRequest( - () => - // TODO: replace with real API call - new Promise(resolve => setTimeout(resolve, 1000)) - ).then(() => { + let reqBody: Pick + if (newAccess === 'onlyInvitedPeople') { + reqBody = { privileges: false } + } else if ( + newAccess === 'anyoneInXyzWithTheLink' && + sharingLinkData?.subscriptionId + ) { + reqBody = { + privileges: privileges || 'readOnly', + subscriptionId: sharingLinkData.subscriptionId, + } + } else if (newAccess === 'anyoneWithTheLink') { + reqBody = { privileges: privileges || 'readOnly' } + } else { + return + } + + monitorRequest(async () => { + const data = await updateSharingLink(projectId, reqBody) + + if (projectAccess === 'legacyLinkSharing') { + await setPublicAccessLevel(projectId, 'private') + } + + return data + }).then(data => { + setSharingLinkData(newAccess === 'onlyInvitedPeople' ? null : data) setProjectAccess(newAccess) setSuccessActionMessage(t('access_updated')) }) } const onAccessSelect = (eventKey: ProjectAccessType) => { - if (projectAccess === 'linkSharing' && eventKey !== 'linkSharing') { + if ( + projectAccess === 'legacyLinkSharing' && + eventKey !== 'legacyLinkSharing' + ) { // Legacy link sharing: show confirmation first setPendingAccess(eventKey as PendingAccessType) } else { @@ -75,25 +111,28 @@ function ProjectAccess({ } } - const onPrivilegesChange = (eventKey: Exclude) => { - monitorRequest( - () => - // TODO: replace with real API call - new Promise(resolve => setTimeout(resolve, 1000)) - ).then(() => { - setPrivileges(eventKey) + const onPrivilegesChange = ( + eventKey: ExcludeStrict + ) => { + monitorRequest(() => + updateSharingLink(projectId, { + privileges: eventKey, + subscriptionId: sharingLinkData?.subscriptionId, + }) + ).then(data => { + setSharingLinkData(data) setSuccessActionMessage(t('access_updated')) }) } const getProjectAccessDropdownToggleText = () => { switch (projectAccess) { - case 'linkSharing': + case 'legacyLinkSharing': return t('via_sharing_links_legacy') case 'onlyInvitedPeople': return t('only_invited_people') case 'anyoneInXyzWithTheLink': - return t('anyone_in_x_with_the_link', { companyName }) + return t('anyone_in_x_with_the_link', { groupName }) case 'anyoneWithTheLink': return t('anyone_with_the_link') default: @@ -122,7 +161,9 @@ function ProjectAccess({ {projectAccess && (
- {projectAccess === 'linkSharing' && } + {projectAccess === 'legacyLinkSharing' && ( + + )} {projectAccess === 'onlyInvitedPeople' && ( )} @@ -141,17 +182,19 @@ function ProjectAccess({ - {projectAccess === 'linkSharing' && ( + {projectAccess === 'legacyLinkSharing' && ( <> } trailingIcon={ - projectAccess === 'linkSharing' ? 'check' : undefined + projectAccess === 'legacyLinkSharing' + ? 'check' + : undefined } - active={projectAccess === 'linkSharing'} + active={projectAccess === 'legacyLinkSharing'} > {t('via_sharing_links_legacy')} @@ -174,21 +217,23 @@ function ProjectAccess({ {t('only_invited_people')} - - } - trailingIcon={ - projectAccess === 'anyoneInXyzWithTheLink' - ? 'check' - : undefined - } - active={projectAccess === 'anyoneInXyzWithTheLink'} - > - {t('anyone_in_x_with_the_link', { companyName })} - - + {sharingLinkData?.subscriptionId && ( + + } + trailingIcon={ + projectAccess === 'anyoneInXyzWithTheLink' + ? 'check' + : undefined + } + active={projectAccess === 'anyoneInXyzWithTheLink'} + > + {t('anyone_in_x_with_the_link', { groupName })} + + + )} {t('anyone_with_the_link')} - - - - - @@ -227,7 +259,7 @@ function ProjectAccess({ /> )}
- {projectAccess !== 'linkSharing' && ( + {projectAccess !== 'legacyLinkSharing' && privileges && ( import('./share-modal-body')) type ShareProjectModalContentProps = { cancel: () => void + onShow: () => void show: boolean animation: boolean inFlight: boolean @@ -42,6 +43,7 @@ type ShareProjectModalContentProps = { export default function ShareProjectModalContent({ show, + onShow, cancel, animation, inFlight, @@ -49,7 +51,7 @@ export default function ShareProjectModalContent({ projectName, }: ShareProjectModalContentProps) { return ( - + > + sharingLinkData: SharingLinkData | null + setSharingLinkData: (data: SharingLinkData | null) => void } const SHOW_MODAL_COOLDOWN_PERIOD = 24 * 60 * 60 * 1000 // 24 hours @@ -72,6 +79,8 @@ const ShareProjectModal = React.memo(function ShareProjectModal({ const [inFlight, setInFlight] = useState(false) const [error, setError] = useState() + const [sharingLinkData, setSharingLinkData] = + useState(null) const [projectAccess, setProjectAccess] = useState< ProjectAccessType | undefined >() @@ -83,18 +92,6 @@ const ShareProjectModal = React.memo(function ShareProjectModal({ const { isProjectOwner } = useEditorContext() const { publicAccessLevel } = project || {} - // TODO: handle initial state for projectAccess - useEffect(() => { - if (!projectAccess) { - if (publicAccessLevel === 'tokenBased') { - // consider a legacy link sharing is enabled - setProjectAccess('linkSharing') - } else { - setProjectAccess('onlyInvitedPeople') - } - } - }, [projectAccess, publicAccessLevel]) - const { splitTestVariants } = useSplitTestContext() // show the new share modal if project owner @@ -149,6 +146,41 @@ const ShareProjectModal = React.memo(function ShareProjectModal({ } }, [show]) + const handleShow = useCallback(async () => { + if (publicAccessLevel === 'tokenBased') { + setSharingLinkData(null) + setProjectAccess('legacyLinkSharing') + return + } + + try { + const data = await getSharingLink(projectId) + + setSharingLinkData(data) + + if (!data.privileges) { + setProjectAccess('onlyInvitedPeople') + } else if (data.subscriptionId) { + setProjectAccess('anyoneInXyzWithTheLink') + } else { + setProjectAccess('anyoneWithTheLink') + } + } catch (error) { + if (error instanceof FetchError && error.response?.status === 404) { + setSharingLinkData(null) + setProjectAccess('onlyInvitedPeople') + return + } + + const errorData = (error as { data?: Record })?.data + setError( + errorData?.errorReason || + errorData?.error || + 'generic_something_went_wrong' + ) + } + }, [publicAccessLevel, projectId]) + // close the modal if not in flight const cancel = useCallback(() => { if (!inFlight) { @@ -196,10 +228,13 @@ const ShareProjectModal = React.memo(function ShareProjectModal({ setSuccessActionMessage, projectAccess, setProjectAccess, + sharingLinkData, + setSharingLinkData, }} > (`/project/${projectId}/sharing-link`) +} + +export function updateSharingLink( + projectId: string, + data: Pick +) { + return postJSON(`/project/${projectId}/sharing-link`, { + body: data, + }) +} + export function sendInvite( projectId: string, email: string, diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 329c0ea8a9..a3d9dc99e2 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -203,7 +203,7 @@ "anonymous": "Anonymous", "anyone": "Anyone", "anyone_in_x": "Anyone in __groupName__", - "anyone_in_x_with_the_link": "Anyone in __companyName__ with the link", + "anyone_in_x_with_the_link": "Anyone in __groupName__ with the link", "anyone_with_link_can_edit": "Anyone with this link can edit this project", "anyone_with_link_can_view": "Anyone with this link can view this project", "anyone_with_the_link": "Anyone with the link", @@ -1733,7 +1733,6 @@ "not_found_error_from_the_supplied_url": "The link to open this content on Overleaf pointed to a file that could not be found. If this keeps happening for links on a particular site, please report this to them.", "not_managed": "Not managed", "not_now": "Not now", - "not_permitted_by_your_organization": "Not permitted by your organization", "not_registered": "Not registered", "note_features_under_development": "<0>Please note that features in this program are still being tested and actively developed. This means that they might <0>change, be <0>removed or <0>become part of a premium plan", "notification": "Notification", diff --git a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx index 01146fecea..ba5a6e9786 100644 --- a/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx +++ b/services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx @@ -998,6 +998,70 @@ describe('', function () { }) }) + describe('sharing-updates feature flag enabled', function () { + beforeEach(function () { + window.metaAttributesCache.set('ol-splitTestVariants', { + 'sharing-updates': 'enabled', + }) + }) + + it('sets "Via sharing links (legacy)" when `publicAccessLevel` is `tokenBased`', async function () { + fetchMock.get(`/project/${shareModalProjectDefaults._id}/tokens`, {}) + + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'tokenBased' }) + ) + + await screen.findByText('Via sharing links (legacy)') + }) + + it('sets "Only invited people" when sharing-link returns 404', async function () { + fetchMock.get( + `/project/${shareModalProjectDefaults._id}/sharing-link`, + 404 + ) + + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'private' }) + ) + + await screen.findByText('Only invited people') + }) + + it('sets "Anyone with the link" when sharing-link returns a link without `subscriptionId`', async function () { + fetchMock.get(`/project/${shareModalProjectDefaults._id}/sharing-link`, { + _id: 'link-id', + token: 'abc123', + privileges: 'readOnly', + }) + + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'private' }) + ) + + await screen.findByText('Anyone with the link') + }) + + it('sets "Anyone in your group with the link" when sharing-link returns a link with subscriptionId', async function () { + fetchMock.get(`/project/${shareModalProjectDefaults._id}/sharing-link`, { + _id: 'link-id', + token: 'abc123', + privileges: 'readOnly', + subscriptionId: 'sub-123', + }) + + renderWithEditorContext( + , + createContextProps({ publicAccessLevel: 'private' }) + ) + + await screen.findByText('Anyone in your group with the link') + }) + }) + it('allows an email address to be selected, removed, then re-added', async function () { renderWithEditorContext( , diff --git a/services/web/types/utils.ts b/services/web/types/utils.ts index 4c38c9d4cd..07b9f45353 100644 --- a/services/web/types/utils.ts +++ b/services/web/types/utils.ts @@ -21,6 +21,8 @@ export type MergeAndOverride = Own & Omit export type Keys = (keyof T)[] +export type ExcludeStrict = Exclude + /** * Helper to create type guards for literal unions *