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
This commit is contained in:
ilkin-overleaf
2026-06-10 08:06:55 +00:00
committed by Copybot
parent 9dde7d3d71
commit 952266da94
9 changed files with 234 additions and 75 deletions
@@ -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": "",
@@ -67,7 +67,7 @@ export default function LinkSharing() {
}
if (isSharingUpdatesEnabled) {
if (projectAccess === 'linkSharing') {
if (projectAccess === 'legacyLinkSharing') {
return <ReadAndWriteTokenLinks />
}
return null
@@ -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<React.SetStateAction<boolean>>
invitedPeopleCount: number
}
export type PendingAccessType = Exclude<ProjectAccessType, 'linkSharing'>
export type PendingAccessType = ExcludeStrict<
ProjectAccessType,
'legacyLinkSharing'
>
function ProjectAccess({
setIsInvitedPeopleScreen,
@@ -38,35 +47,62 @@ function ProjectAccess({
const [pendingAccess, setPendingAccess] = useState<PendingAccessType | null>(
null
)
// TODO set initial state
const [privileges, setPrivileges] =
useState<Exclude<PermissionsLevel, 'owner'>>('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<SharingLinkData, 'privileges' | 'subscriptionId'>
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<PermissionsLevel, 'owner'>) => {
monitorRequest(
() =>
// TODO: replace with real API call
new Promise(resolve => setTimeout(resolve, 1000))
).then(() => {
setPrivileges(eventKey)
const onPrivilegesChange = (
eventKey: ExcludeStrict<SharingLinkPrivileges, false>
) => {
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 && (
<ShareProjectModalRow>
<div className="d-inline-flex align-items-center h5 m-0">
{projectAccess === 'linkSharing' && <MaterialIcon type="link" />}
{projectAccess === 'legacyLinkSharing' && (
<MaterialIcon type="link" />
)}
{projectAccess === 'onlyInvitedPeople' && (
<MaterialIcon type="lock" unfilled />
)}
@@ -141,17 +182,19 @@ function ProjectAccess({
<MaterialIcon type="keyboard_arrow_down" />
</DropdownToggle>
<DropdownMenu>
{projectAccess === 'linkSharing' && (
{projectAccess === 'legacyLinkSharing' && (
<>
<DropdownListItem className="d-flex align-items-center">
<DropdownItem
as="button"
eventKey="linkSharing"
eventKey="legacyLinkSharing"
leadingIcon={<MaterialIcon type="link" />}
trailingIcon={
projectAccess === 'linkSharing' ? 'check' : undefined
projectAccess === 'legacyLinkSharing'
? 'check'
: undefined
}
active={projectAccess === 'linkSharing'}
active={projectAccess === 'legacyLinkSharing'}
>
{t('via_sharing_links_legacy')}
</DropdownItem>
@@ -174,21 +217,23 @@ function ProjectAccess({
{t('only_invited_people')}
</DropdownItem>
</DropdownListItem>
<DropdownListItem className="d-flex align-items-center">
<DropdownItem
as="button"
eventKey="anyoneInXyzWithTheLink"
leadingIcon={<MaterialIcon type="domain" unfilled />}
trailingIcon={
projectAccess === 'anyoneInXyzWithTheLink'
? 'check'
: undefined
}
active={projectAccess === 'anyoneInXyzWithTheLink'}
>
{t('anyone_in_x_with_the_link', { companyName })}
</DropdownItem>
</DropdownListItem>
{sharingLinkData?.subscriptionId && (
<DropdownListItem className="d-flex align-items-center">
<DropdownItem
as="button"
eventKey="anyoneInXyzWithTheLink"
leadingIcon={<MaterialIcon type="domain" unfilled />}
trailingIcon={
projectAccess === 'anyoneInXyzWithTheLink'
? 'check'
: undefined
}
active={projectAccess === 'anyoneInXyzWithTheLink'}
>
{t('anyone_in_x_with_the_link', { groupName })}
</DropdownItem>
</DropdownListItem>
)}
<DropdownListItem className="d-flex align-items-center gap-2">
<DropdownItem
as="button"
@@ -203,19 +248,6 @@ function ProjectAccess({
>
{t('anyone_with_the_link')}
</DropdownItem>
<OLTooltip
id="tooltip-anyone-with-link"
description={t('not_permitted_by_your_organization')}
overlayProps={{ placement: 'left' }}
>
<span style={{ cursor: 'default' }}>
<MaterialIcon
type="info"
unfilled
className="align-middle px-2"
/>
</span>
</OLTooltip>
</DropdownListItem>
</DropdownMenu>
</Dropdown>
@@ -227,7 +259,7 @@ function ProjectAccess({
/>
)}
</div>
{projectAccess !== 'linkSharing' && (
{projectAccess !== 'legacyLinkSharing' && privileges && (
<Dropdown align="end" onSelect={onPrivilegesChange}>
<DropdownToggle
variant="ghost"
@@ -33,6 +33,7 @@ const ShareModalBody = lazy(() => 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 (
<OLModal show={show} onHide={cancel} animation={animation}>
<OLModal show={show} onShow={onShow} onHide={cancel} animation={animation}>
<ShareProjectModalContentInnerWithErrorBoundary
inFlight={inFlight}
error={error}
@@ -11,9 +11,14 @@ import { useSplitTestContext } from '@/shared/context/split-test-context'
import { sendMB } from '@/infrastructure/event-tracking'
import { useEditorContext } from '@/shared/context/editor-context'
import customLocalStorage from '@/infrastructure/local-storage'
import { FetchError } from '@/infrastructure/fetch-json'
import {
getSharingLink,
SharingLinkData,
} from '@/features/share-project-modal/utils/api'
export type ProjectAccessType =
| 'linkSharing'
| 'legacyLinkSharing'
| 'onlyInvitedPeople'
| 'anyoneInXyzWithTheLink'
| 'anyoneWithTheLink'
@@ -36,6 +41,8 @@ export type ShareProjectContextValue = {
setProjectAccess: React.Dispatch<
React.SetStateAction<ProjectAccessType | undefined>
>
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<ShareProjectContextValue['inFlight']>(false)
const [error, setError] = useState<ShareProjectContextValue['error']>()
const [sharingLinkData, setSharingLinkData] =
useState<SharingLinkData | null>(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<string, string> })?.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,
}}
>
<ShareProjectModalContent
animation={animation}
onShow={handleShow}
cancel={cancel}
error={error}
inFlight={inFlight}
@@ -9,6 +9,32 @@ import { executeV2Captcha } from './captcha'
import getMeta from '@/utils/meta'
import { PermissionsLevel } from '@/features/ide-react/types/permissions'
export type SharingLinkPrivileges =
| 'readAndWrite'
| 'review'
| 'readOnly'
| false
export type SharingLinkData = {
_id: string
token: string
privileges: SharingLinkPrivileges
subscriptionId?: string
}
export function getSharingLink(projectId: string) {
return getJSON<SharingLinkData>(`/project/${projectId}/sharing-link`)
}
export function updateSharingLink(
projectId: string,
data: Pick<SharingLinkData, 'privileges' | 'subscriptionId'>
) {
return postJSON<SharingLinkData>(`/project/${projectId}/sharing-link`, {
body: data,
})
}
export function sendInvite(
projectId: string,
email: string,
+1 -2
View File
@@ -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</0> that features in this program are still being tested and actively developed. This means that they might <0>change</0>, be <0>removed</0> or <0>become part of a premium plan</0>",
"notification": "Notification",
@@ -998,6 +998,70 @@ describe('<ShareProjectModal/>', 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(
<ShareProjectModal {...modalProps} />,
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(
<ShareProjectModal {...modalProps} />,
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(
<ShareProjectModal {...modalProps} />,
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(
<ShareProjectModal {...modalProps} />,
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(
<ShareProjectModal {...modalProps} />,
+2
View File
@@ -21,6 +21,8 @@ export type MergeAndOverride<Parent, Own> = Own & Omit<Parent, keyof Own>
export type Keys<T extends object> = (keyof T)[]
export type ExcludeStrict<T, U extends T> = Exclude<T, U>
/**
* Helper to create type guards for literal unions
*