mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
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:
@@ -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
|
||||
|
||||
+89
-57
@@ -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"
|
||||
|
||||
+3
-1
@@ -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}
|
||||
|
||||
+48
-13
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+64
@@ -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} />,
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user