mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-14 22:23:42 +01:00
Merge pull request #34605 from overleaf/tm-request-access-redesign
[WEB] Request edit access in the new share modal GitOrigin-RevId: d01cf4d038d5ce07c34deac0714b0cdad02136be
This commit is contained in:
+68
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import getMeta from '@/utils/meta'
|
||||
import RequestAccessModal from './request-access-modal'
|
||||
|
||||
export default function RequestAccessButton() {
|
||||
const { t } = useTranslation()
|
||||
// Gate on the project owner's split-test bucket rather than the
|
||||
// viewer's: a viewer outside the experiment should still be able to
|
||||
// ask whenever the owner can see/action the request in the new share
|
||||
// modal. Computed server-side at editor load and surfaced as a meta
|
||||
// tag, so it never changes for the life of the editor session.
|
||||
const ownerHasSharingUpdates = getMeta('ol-ownerHasSharingUpdates')
|
||||
const { permissionsLevel } = useIdeReactContext()
|
||||
const { project } = useProjectContext()
|
||||
const { id: userId } = useUserContext()
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [locallyRequested, setLocallyRequested] = useState(false)
|
||||
|
||||
const handleOpen = useCallback(() => setShowModal(true), [])
|
||||
const handleClose = useCallback(() => setShowModal(false), [])
|
||||
const handleSuccess = useCallback(() => {
|
||||
setLocallyRequested(true)
|
||||
setShowModal(false)
|
||||
}, [])
|
||||
|
||||
if (!ownerHasSharingUpdates) return null
|
||||
if (permissionsLevel !== 'readOnly' && permissionsLevel !== 'review')
|
||||
return null
|
||||
if (!userId) return null
|
||||
|
||||
// Once the owner grants *editor* access the requester becomes a named
|
||||
// collaborator with nothing left to request, so hide the button as soon as
|
||||
// they show up as an editor in the (live-refetched) members list — their
|
||||
// own permissionsLevel only updates on reload. Reviewers are intentionally
|
||||
// not hidden here: they can still request editor access.
|
||||
const isNowEditor = project?.members?.some(
|
||||
member => member._id === userId && member.privileges === 'readAndWrite'
|
||||
)
|
||||
if (isNowEditor) return null
|
||||
|
||||
const hasRequested =
|
||||
locallyRequested || Boolean(project?.myAccessRequest?.privilegeLevel)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ide-redesign-toolbar-button-container">
|
||||
<OLButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={hasRequested}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
{hasRequested ? t('edit_access_requested') : t('request_edit_access')}
|
||||
</OLButton>
|
||||
</div>
|
||||
<RequestAccessModal
|
||||
show={showModal}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={handleClose}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLSpinner from '@/shared/components/ol/ol-spinner'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import DropdownMenuItem from '@/shared/components/dropdown/dropdown-menu-item'
|
||||
import {
|
||||
OLDropdown,
|
||||
OLDropdownMenu,
|
||||
OLDropdownToggle,
|
||||
} from '@/shared/components/ol/ol-dropdown-menu'
|
||||
import { requestAccess } from '@/features/share-project-modal/utils/api'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import { RequestedPrivilegeLevel } from '@/shared/context/types/project-metadata'
|
||||
|
||||
// `as const` keeps the icon names as literals so they satisfy
|
||||
// MaterialIcon's `type` prop when combined with `unfilled` (a widened
|
||||
// `string` would resolve to the filled-icon overload and fail type-check).
|
||||
const LEVEL_OPTIONS = [
|
||||
{ key: 'readAndWrite', icon: 'edit' },
|
||||
{ key: 'review', icon: 'mode_comment' },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: RequestedPrivilegeLevel
|
||||
icon: string
|
||||
}>
|
||||
|
||||
export default function RequestAccessModal({
|
||||
show,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: {
|
||||
show: boolean
|
||||
onSuccess: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { projectId } = useProjectContext()
|
||||
const { email } = useUserContext()
|
||||
const { permissionsLevel } = useIdeReactContext()
|
||||
const { isLoading, isError, runAsync } = useAsync()
|
||||
|
||||
// Reviewers can only step up to editor; viewers can choose editor or
|
||||
// reviewer. The requestable set always excludes the level the user
|
||||
// already holds.
|
||||
const options =
|
||||
permissionsLevel === 'review'
|
||||
? LEVEL_OPTIONS.filter(o => o.key === 'readAndWrite')
|
||||
: LEVEL_OPTIONS
|
||||
|
||||
const [privilegeLevel, setPrivilegeLevel] =
|
||||
useState<RequestedPrivilegeLevel>('readAndWrite')
|
||||
|
||||
// Built with static t() calls (not t(dynamicKey)) so the keys are picked up
|
||||
// by the translation extractor and never render as raw key strings.
|
||||
const levelLabels: Record<RequestedPrivilegeLevel, string> = {
|
||||
readAndWrite: t('editor'),
|
||||
review: t('reviewer'),
|
||||
}
|
||||
|
||||
function submit() {
|
||||
runAsync(requestAccess(projectId, privilegeLevel))
|
||||
.then(() => onSuccess())
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<OLModal show={show} onHide={onCancel}>
|
||||
<OLModalHeader>
|
||||
<OLModalTitle>{t('request_edit_access')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
<OLModalBody>
|
||||
<p>{t('signed_in_as_email', { email })}</p>
|
||||
<OLFormGroup>
|
||||
<OLFormLabel>{t('what_access_do_you_want')}</OLFormLabel>
|
||||
<div>
|
||||
<OLDropdown
|
||||
onSelect={(eventKey: RequestedPrivilegeLevel) =>
|
||||
eventKey && setPrivilegeLevel(eventKey)
|
||||
}
|
||||
>
|
||||
<OLDropdownToggle
|
||||
variant="ghost"
|
||||
disabled={isLoading}
|
||||
className="d-flex align-items-center gap-2 no-default-caret"
|
||||
>
|
||||
{levelLabels[privilegeLevel]}
|
||||
<MaterialIcon type="keyboard_arrow_down" />
|
||||
</OLDropdownToggle>
|
||||
<OLDropdownMenu>
|
||||
{options.map(option => (
|
||||
<DropdownMenuItem
|
||||
key={option.key}
|
||||
as="button"
|
||||
eventKey={option.key}
|
||||
leadingIcon={<MaterialIcon type={option.icon} unfilled />}
|
||||
active={privilegeLevel === option.key}
|
||||
trailingIcon={
|
||||
privilegeLevel === option.key ? 'check' : undefined
|
||||
}
|
||||
>
|
||||
{levelLabels[option.key]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</OLDropdownMenu>
|
||||
</OLDropdown>
|
||||
</div>
|
||||
</OLFormGroup>
|
||||
<p className="text-muted small mb-0">
|
||||
{t(
|
||||
'your_name_and_email_address_will_be_visible_to_the_project_owner_and_other_editors'
|
||||
)}
|
||||
</p>
|
||||
{isError && (
|
||||
<Notification
|
||||
type="error"
|
||||
content={t('generic_something_went_wrong')}
|
||||
className="mb-0 mt-3"
|
||||
/>
|
||||
)}
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<div className="me-auto">{isLoading && <OLSpinner size="sm" />}</div>
|
||||
<OLButton variant="secondary" onClick={onCancel} disabled={isLoading}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton variant="primary" onClick={submit} disabled={isLoading}>
|
||||
{t('send_request')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
+31
-1
@@ -1,16 +1,25 @@
|
||||
import ShareProjectModal from '@/features/share-project-modal/components/share-project-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLBadge from '@/shared/components/ol/ol-badge'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
|
||||
import { useEditorContext } from '@/shared/context/editor-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
|
||||
export default function ShareProjectButton() {
|
||||
const { t } = useTranslation()
|
||||
const { sendEventOnce } = useEditorAnalytics()
|
||||
const { isProjectOwner } = useEditorContext()
|
||||
const { project } = useProjectContext()
|
||||
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
|
||||
const accessRequestCount = isProjectOwner
|
||||
? (project?.editAccessRequests?.length ?? 0)
|
||||
: 0
|
||||
|
||||
const handleOpenShareModal = useCallback(() => {
|
||||
sendEventOnce('ide-open-share-modal-once')
|
||||
setShowShareModal(true)
|
||||
@@ -20,6 +29,22 @@ export default function ShareProjectButton() {
|
||||
setShowShareModal(false)
|
||||
}, [])
|
||||
|
||||
// Open the share modal automatically when the editor is loaded with
|
||||
// ?share=1 (used by the "Manage sharing" CTA in the access-request
|
||||
// email). Strip the flag so a reload doesn't reopen it.
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get('share') !== '1') return
|
||||
setShowShareModal(true)
|
||||
params.delete('share')
|
||||
const search = params.toString()
|
||||
const newUrl =
|
||||
window.location.pathname +
|
||||
(search ? `?${search}` : '') +
|
||||
window.location.hash
|
||||
window.history.replaceState(null, '', newUrl)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ide-redesign-toolbar-button-container">
|
||||
@@ -30,6 +55,11 @@ export default function ShareProjectButton() {
|
||||
onClick={handleOpenShareModal}
|
||||
>
|
||||
{t('share')}
|
||||
{accessRequestCount > 0 && (
|
||||
<OLBadge pill bg="warning" className="lh-1">
|
||||
{accessRequestCount}
|
||||
</OLBadge>
|
||||
)}
|
||||
</OLButton>
|
||||
</div>
|
||||
<ShareProjectModal
|
||||
|
||||
@@ -5,6 +5,7 @@ import { OnlineUsers } from './online-users'
|
||||
import ShareProjectButton from './share-project-button'
|
||||
import ChangeLayoutButton from './change-layout-button'
|
||||
import ShowHistoryButton from './show-history-button'
|
||||
import RequestAccessButton from './request-access-button'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
import BackToEditorButton from '@/features/editor-navigation-toolbar/components/back-to-editor-button'
|
||||
import { useCallback } from 'react'
|
||||
@@ -134,6 +135,7 @@ export const Toolbar = () => {
|
||||
)}
|
||||
<div className="ide-redesign-toolbar-actions">
|
||||
{!isOfflineDueToNetworkStall && <OnlineUsers />}
|
||||
<RequestAccessButton />
|
||||
{!isRestrictedTokenMember && <ShowHistoryButton />}
|
||||
<ChangeLayoutButton />
|
||||
{shouldDisplaySubmitButton && cobranding && (
|
||||
|
||||
Reference in New Issue
Block a user