Merge pull request #32924 from overleaf/ii-share-modal-basic-layout

[web] Share modal redesign base layout

GitOrigin-RevId: 0f011d03762c6669a0fd3b1fc2af32c9026c7ea4
This commit is contained in:
ilkin-overleaf
2026-04-28 08:06:13 +00:00
committed by Copybot
parent 5d3d58e8f4
commit 816f8c45eb
18 changed files with 747 additions and 122 deletions
@@ -481,6 +481,7 @@ const _ProjectController = {
'editor-tabs',
'overleaf-code',
'export-docx',
'sharing-updates',
].filter(Boolean)
const getUserValues = async userId =>
@@ -51,6 +51,7 @@
"access_denied": "",
"access_edit_your_projects": "",
"access_levels_changed": "",
"access_updated": "",
"account_associated_with_x": "",
"account_billed_manually": "",
"account_has_been_link_to_group_account": "",
@@ -150,8 +151,10 @@
"anonymous": "",
"anyone": "",
"anyone_in_x": "",
"anyone_in_x_with_the_link": "",
"anyone_with_link_can_edit": "",
"anyone_with_link_can_view": "",
"anyone_with_the_link": "",
"app_on_x": "",
"appearance": "",
"apply": "",
@@ -1097,6 +1100,7 @@
"make_owner": "",
"make_primary": "",
"make_private": "",
"manage_access": "",
"manage_beta_program_membership": "",
"manage_email_preferences": "",
"manage_files_from_your_dropbox_folder": "",
@@ -1245,6 +1249,7 @@
"not_captured": "",
"not_managed": "",
"not_now": "",
"not_permitted_by_your_organization": "",
"notification": "",
"notification_personal_and_group_subscriptions": "",
"notification_project_invite_accepted_message": "",
@@ -1268,6 +1273,7 @@
"only_group_admin_or_managers_can_delete_your_account_7": "",
"only_group_admin_or_managers_can_delete_your_account_9": "",
"only_importer_can_refresh": "",
"only_invited_people": "",
"only_project_owner_can_link_github": "",
"open_action_menu": "",
"open_advanced_reference_search": "",
@@ -1426,6 +1432,7 @@
"processing_uppercase": "",
"professional": "",
"progress_bar_percentage": "",
"project_access": "",
"project_actions": "",
"project_approaching_file_limit": "",
"project_categories_tags": "",
@@ -1539,6 +1546,8 @@
"remove_link": "",
"remove_manager": "",
"remove_or_replace_figure": "",
"remove_original_links": "",
"remove_original_sharing_links": "",
"remove_secondary_email_addresses": "",
"remove_secondary_email_addresses_except_saml_email": "",
"remove_sso_login_option": "",
@@ -1722,6 +1731,7 @@
"share": "",
"share_feedback": "",
"share_project": "",
"share_project_name": "",
"shared_with_you": "",
"sharelatex_beta_program": "",
"sharing_permissions": "",
@@ -1970,6 +1980,8 @@
"this_action_cannot_be_undone": "",
"this_action_will_also_disable_domain_capture": "",
"this_address_will_be_shown_on_the_invoice": "",
"this_change_will_permanently_remove_your_original_links_you_can_still_share_new_link": "",
"this_change_will_permanently_remove_your_original_links_you_need_to_share_the_new_link": "",
"this_could_be_because_we_cant_support_some_elements_of_the_table": "",
"this_field_is_required": "",
"this_grants_access_to_features_2": "",
@@ -2233,6 +2245,7 @@
"verify_email_address_before_enabling_managed_users": "",
"verify_your_email_address": "",
"verifying_domain": "",
"via_sharing_links_legacy": "",
"view": "",
"view_all": "",
"view_all_plans": "",
@@ -2315,6 +2328,8 @@
"x_changes_in": "",
"x_changes_in_plural": "",
"x_libraries_accessed_in_this_project": "",
"x_people_invited": "",
"x_people_invited_plural": "",
"x_price_for_first_month": "",
"x_price_for_first_year": "",
"x_price_for_y_months": "",
@@ -15,6 +15,7 @@ export default /** @type {const} */ ([
'cached',
'call_split',
'check',
'check_circle',
'chevron_left',
'chevron_right',
'close',
@@ -24,6 +25,8 @@ export default /** @type {const} */ ([
'delete_forever',
'delete',
'description',
'domain',
'edit',
'edit_square',
'error',
'experiment',
@@ -31,6 +34,8 @@ export default /** @type {const} */ ([
'format_strikethrough',
'forum',
'functions',
'globe',
'group',
'help',
'image',
'info',
@@ -40,6 +45,7 @@ export default /** @type {const} */ ([
'lightbulb_2',
'lock',
'lock_open',
'mode_comment',
'more_vert',
'neurology',
'note_add',
@@ -67,6 +73,7 @@ export default /** @type {const} */ ([
'turn_left',
'upload_file',
'upload',
'visibility',
'visibility_off',
'web_asset',
'whatshot',
@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next'
import { ShareProjectContextValue } from '@/features/share-project-modal/components/share-project-modal'
function ErrorMessage({ error }: Pick<ShareProjectContextValue, 'error'>) {
const { t } = useTranslation()
switch (error) {
case 'cannot_invite_non_user':
return <>{t('cannot_invite_non_user')}</>
case 'cannot_verify_user_not_robot':
return <>{t('cannot_verify_user_not_robot')}</>
case 'cannot_invite_self':
return <>{t('cannot_invite_self')}</>
case 'invalid_email':
return <>{t('invalid_email')}</>
case 'too_many_requests':
return <>{t('too_many_requests')}</>
case 'invite_expired':
return <>{t('invite_expired')}</>
case 'invite_resend_limit_hit':
return <>{t('invite_resend_limit_hit')}</>
default:
return <>{t('generic_something_went_wrong')}</>
}
}
export default ErrorMessage
@@ -0,0 +1,7 @@
type InvitedPeopleProps = object
function InvitedPeople(_props: InvitedPeopleProps) {
return null
}
export default InvitedPeople
@@ -16,6 +16,7 @@ import OLCol from '@/shared/components/ol/ol-col'
import OLButton from '@/shared/components/ol/ol-button'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import { useFeatureFlag } from '@/shared/context/split-test-context'
type Tokens = {
readAndWrite: string
@@ -28,12 +29,13 @@ type Tokens = {
type AccessLevel = 'private' | 'tokenBased' | 'readAndWrite' | 'readOnly'
export default function LinkSharing() {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const [inflight, setInflight] = useState(false)
const [showLinks, setShowLinks] = useState(true)
const linkSharingEnabled =
getMeta('ol-capabilities')?.includes('link-sharing')
const { monitorRequest } = useShareProjectContext()
const { monitorRequest, projectAccess } = useShareProjectContext()
const { projectId, project } = useProjectContext()
const { publicAccessLevel } = project || {}
@@ -64,6 +66,13 @@ export default function LinkSharing() {
return null
}
if (isSharingUpdatesEnabled) {
if (projectAccess === 'linkSharing') {
return <ReadAndWriteTokenLinks />
}
return null
}
switch (publicAccessLevel) {
// Private (with token-access available)
case 'private':
@@ -140,6 +149,20 @@ function PrivateSharing({
)
}
function useProjectTokens() {
const { projectId } = useProjectContext()
const [tokens, setTokens] = useState<Tokens | null>(null)
const { signal } = useAbortController()
useEffect(() => {
getJSON(`/project/${projectId}/tokens`, { signal })
.then(data => setTokens(data))
.catch(debugConsole.error)
}, [projectId, signal])
return tokens
}
function TokenBasedSharing({
setAccessLevel,
inflight,
@@ -152,17 +175,7 @@ function TokenBasedSharing({
showLinks: boolean
}) {
const { t } = useTranslation()
const { projectId } = useProjectContext()
const [tokens, setTokens] = useState<Tokens | null>(null)
const { signal } = useAbortController()
useEffect(() => {
getJSON(`/project/${projectId}/tokens`, { signal })
.then(data => setTokens(data))
.catch(debugConsole.error)
}, [projectId, signal])
const tokens = useProjectTokens()
return (
<OLRow className="public-access-level">
@@ -191,24 +204,8 @@ function TokenBasedSharing({
</OLCol>
{showLinks && (
<OLCol xs={12} className="access-token-display-area">
<div className="access-token-wrapper">
<strong>{t('anyone_with_link_can_edit')}</strong>
<AccessToken
token={tokens?.readAndWrite}
tokenHashPrefix={tokens?.readAndWriteHashPrefix}
path="/"
tooltipId="tooltip-copy-link-rw"
/>
</div>
<div className="access-token-wrapper">
<strong>{t('anyone_with_link_can_view')}</strong>
<AccessToken
token={tokens?.readOnly}
tokenHashPrefix={tokens?.readOnlyHashPrefix}
path="/read/"
tooltipId="tooltip-copy-link-ro"
/>
</div>
<AccessTokenEditDisplayArea tokens={tokens} />
<AccessTokenViewDisplayArea tokens={tokens} />
</OLCol>
)}
</OLRow>
@@ -249,32 +246,26 @@ function LegacySharing({
)
}
export function ReadOnlyTokenLink() {
const { t } = useTranslation()
const { projectId } = useProjectContext()
const [tokens, setTokens] = useState<Tokens | null>(null)
const { signal } = useAbortController()
useEffect(() => {
getJSON(`/project/${projectId}/tokens`, { signal })
.then(data => setTokens(data))
.catch(debugConsole.error)
}, [projectId, signal])
export function ReadAndWriteTokenLinks() {
const tokens = useProjectTokens()
return (
<OLRow className="public-access-level">
<OLCol className="access-token-display-area">
<div className="access-token-wrapper">
<strong>{t('anyone_with_link_can_view')}</strong>
<AccessToken
token={tokens?.readOnly}
tokenHashPrefix={tokens?.readOnlyHashPrefix}
path="/read/"
tooltipId="tooltip-copy-link-ro"
/>
</div>
<AccessTokenEditDisplayArea tokens={tokens} />
<AccessTokenViewDisplayArea tokens={tokens} />
</OLCol>
</OLRow>
)
}
export function ReadOnlyTokenLink() {
const tokens = useProjectTokens()
return (
<OLRow className="public-access-level">
<OLCol className="access-token-display-area">
<AccessTokenViewDisplayArea tokens={tokens} />
</OLCol>
</OLRow>
)
@@ -336,3 +327,39 @@ function LinkSharingInfo() {
</OLTooltip>
)
}
function AccessTokenEditDisplayArea({ tokens }: { tokens: Tokens | null }) {
const { t } = useTranslation()
return (
<div className="access-token-wrapper">
<strong className="access-token-wrapper-title">
{t('anyone_with_link_can_edit')}
</strong>
<AccessToken
token={tokens?.readAndWrite}
tokenHashPrefix={tokens?.readAndWriteHashPrefix}
path="/"
tooltipId="tooltip-copy-link-rw"
/>
</div>
)
}
function AccessTokenViewDisplayArea({ tokens }: { tokens: Tokens | null }) {
const { t } = useTranslation()
return (
<div className="access-token-wrapper">
<strong className="access-token-wrapper-title">
{t('anyone_with_link_can_view')}
</strong>
<AccessToken
token={tokens?.readOnly}
tokenHashPrefix={tokens?.readOnlyHashPrefix}
path="/read/"
tooltipId="tooltip-copy-link-ro"
/>
</div>
)
}
@@ -0,0 +1,280 @@
import { useState } from 'react'
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,
DropdownDivider,
DropdownItem,
DropdownMenu,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
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 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'
type ProjectAccessProps = {
setIsInvitedPeopleScreen: React.Dispatch<React.SetStateAction<boolean>>
invitedPeopleCount: number
}
export type PendingAccessType = Exclude<ProjectAccessType, 'linkSharing'>
function ProjectAccess({
setIsInvitedPeopleScreen,
invitedPeopleCount,
}: ProjectAccessProps) {
const { t } = useTranslation()
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'
const {
monitorRequest,
setSuccessActionMessage,
projectAccess,
setProjectAccess,
} = useShareProjectContext()
const handleAccessChange = (newAccess: PendingAccessType) => {
setPendingAccess(null)
monitorRequest(
() =>
// TODO: replace with real API call
new Promise(resolve => setTimeout(resolve, 1000))
).then(() => {
setProjectAccess(newAccess)
setSuccessActionMessage(t('access_updated'))
})
}
const onAccessSelect = (eventKey: ProjectAccessType) => {
if (projectAccess === 'linkSharing' && eventKey !== 'linkSharing') {
// Legacy link sharing: show confirmation first
setPendingAccess(eventKey as PendingAccessType)
} else {
// Non-legacy: fire request directly
handleAccessChange(eventKey as PendingAccessType)
}
}
const onPrivilegesChange = (eventKey: Exclude<PermissionsLevel, 'owner'>) => {
monitorRequest(
() =>
// TODO: replace with real API call
new Promise(resolve => setTimeout(resolve, 1000))
).then(() => {
setPrivileges(eventKey)
setSuccessActionMessage(t('access_updated'))
})
}
const getProjectAccessDropdownToggleText = () => {
switch (projectAccess) {
case 'linkSharing':
return t('via_sharing_links_legacy')
case 'onlyInvitedPeople':
return t('only_invited_people')
case 'anyoneInXyzWithTheLink':
return t('anyone_in_x_with_the_link', { companyName })
case 'anyoneWithTheLink':
return t('anyone_with_the_link')
default:
return ''
}
}
return (
<>
<h3 className="h4 fw-normal mt-3 mb-2 pt-1">{t('project_access')}</h3>
<ShareProjectModalRow>
<div className="d-inline-flex align-items-center h5 m-0 gap-2">
<MaterialIcon type="group" unfilled />
<div className="px-2 fw-normal">
{t('x_people_invited', { count: invitedPeopleCount })}
</div>
</div>
<OLButton
variant="ghost"
trailingIcon="chevron_right"
onClick={() => setIsInvitedPeopleScreen(true)}
>
{t('manage_access')}
</OLButton>
</ShareProjectModalRow>
{projectAccess && (
<ShareProjectModalRow>
<div className="d-inline-flex align-items-center h5 m-0">
{projectAccess === 'linkSharing' && <MaterialIcon type="link" />}
{projectAccess === 'onlyInvitedPeople' && (
<MaterialIcon type="lock" unfilled />
)}
{projectAccess === 'anyoneInXyzWithTheLink' && (
<MaterialIcon type="domain" unfilled />
)}
{projectAccess === 'anyoneWithTheLink' && (
<MaterialIcon type="globe" unfilled />
)}
<Dropdown onSelect={onAccessSelect}>
<DropdownToggle
variant="ghost"
className="d-flex align-items-center gap-2 no-default-caret"
>
{getProjectAccessDropdownToggleText()}
<MaterialIcon type="keyboard_arrow_down" />
</DropdownToggle>
<DropdownMenu>
{projectAccess === 'linkSharing' && (
<>
<DropdownListItem className="d-flex align-items-center">
<DropdownItem
as="button"
eventKey="linkSharing"
leadingIcon={<MaterialIcon type="link" />}
trailingIcon={
projectAccess === 'linkSharing' ? 'check' : undefined
}
active={projectAccess === 'linkSharing'}
>
{t('via_sharing_links_legacy')}
</DropdownItem>
</DropdownListItem>
<DropdownDivider />
</>
)}
<DropdownListItem className="d-flex align-items-center">
<DropdownItem
as="button"
eventKey="onlyInvitedPeople"
leadingIcon={<MaterialIcon type="lock" unfilled />}
trailingIcon={
projectAccess === 'onlyInvitedPeople'
? 'check'
: undefined
}
active={projectAccess === 'onlyInvitedPeople'}
>
{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>
<DropdownListItem className="d-flex align-items-center gap-2">
<DropdownItem
as="button"
eventKey="anyoneWithTheLink"
leadingIcon={<MaterialIcon type="globe" unfilled />}
trailingIcon={
projectAccess === 'anyoneWithTheLink'
? 'check'
: undefined
}
active={projectAccess === 'anyoneWithTheLink'}
>
{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>
{pendingAccess && (
<RemoveSharingLinksModal
pendingAccess={pendingAccess}
onCancel={() => setPendingAccess(null)}
onConfirm={() => handleAccessChange(pendingAccess)}
/>
)}
</div>
{projectAccess !== 'linkSharing' && (
<Dropdown align="end" onSelect={onPrivilegesChange}>
<DropdownToggle
variant="ghost"
className="d-flex align-items-center gap-2 no-default-caret"
>
<MemberPrivileges privileges={privileges} />
<MaterialIcon type="keyboard_arrow_down" />
</DropdownToggle>
<DropdownMenu>
<OLDropdownMenuItem
as="button"
eventKey="readAndWrite"
leadingIcon={<MaterialIcon type="edit" unfilled />}
active={privileges === 'readAndWrite'}
trailingIcon={
privileges === 'readAndWrite' ? 'check' : undefined
}
>
{t('editor')}
</OLDropdownMenuItem>
<OLDropdownMenuItem
as="button"
eventKey="review"
leadingIcon={<MaterialIcon type="mode_comment" unfilled />}
active={privileges === 'review'}
trailingIcon={privileges === 'review' ? 'check' : undefined}
>
{t('reviewer')}
</OLDropdownMenuItem>
<OLDropdownMenuItem
as="button"
eventKey="readOnly"
leadingIcon={<MaterialIcon type="visibility" unfilled />}
active={privileges === 'readOnly'}
trailingIcon={privileges === 'readOnly' ? 'check' : undefined}
>
{t('viewer')}
</OLDropdownMenuItem>
</DropdownMenu>
</Dropdown>
)}
</ShareProjectModalRow>
)}
{isProjectOwner && <LinkSharing />}
</>
)
}
export default ProjectAccess
@@ -0,0 +1,57 @@
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 { PendingAccessType } from '@/features/share-project-modal/components/project-access'
type RemoveSharingLinksModalProps = {
pendingAccess: PendingAccessType
onCancel: () => void
onConfirm: () => void
}
function RemoveSharingLinksModal({
pendingAccess,
onCancel,
onConfirm,
}: RemoveSharingLinksModalProps) {
const { t } = useTranslation()
let confirmationModalBodyText = null
if (pendingAccess === 'onlyInvitedPeople') {
confirmationModalBodyText = t(
'this_change_will_permanently_remove_your_original_links_you_can_still_share_new_link'
)
} else if (
pendingAccess === 'anyoneInXyzWithTheLink' ||
pendingAccess === 'anyoneWithTheLink'
) {
confirmationModalBodyText = t(
'this_change_will_permanently_remove_your_original_links_you_need_to_share_the_new_link'
)
}
return (
<OLModal show onHide={onCancel} size="sm" backdrop={false}>
<OLModalHeader>
<OLModalTitle>{t('remove_original_sharing_links')}</OLModalTitle>
</OLModalHeader>
<OLModalBody>{confirmationModalBodyText}</OLModalBody>
<OLModalFooter>
<OLButton variant="secondary" onClick={onCancel}>
{t('cancel')}
</OLButton>
<OLButton variant="danger" onClick={onConfirm}>
{t('remove_original_links')}
</OLButton>
</OLModalFooter>
</OLModal>
)
}
export default RemoveSharingLinksModal
@@ -3,6 +3,8 @@ import AddCollaboratorsUpgrade from './add-collaborators-upgrade'
import CollaboratorsLimitUpgrade from './collaborators-limit-upgrade'
import AccessLevelsChanged from './access-levels-changed'
import OLRow from '@/shared/components/ol/ol-row'
import classnames from 'classnames'
import { useFeatureFlag } from '@/shared/context/split-test-context'
export default function SendInvites({
canAddCollaborators,
@@ -15,8 +17,18 @@ export default function SendInvites({
haveAnyEditorsBeenDowngraded: boolean
somePendingEditorsResolved: boolean
}) {
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
if (isSharingUpdatesEnabled) {
return null
}
return (
<OLRow className="invite-controls">
<OLRow
className={classnames('invite-controls', {
'pb-3': isSharingUpdatesEnabled,
})}
>
{hasExceededCollaboratorLimit && !haveAnyEditorsBeenDowngraded && (
<AddCollaboratorsUpgrade />
)}
@@ -10,11 +10,27 @@ import { useProjectContext } from '@/shared/context/project-context'
import { useMemo } from 'react'
import RecaptchaConditions from '@/shared/components/recaptcha-conditions'
import getMeta from '@/utils/meta'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import OLNotification from '@/shared/components/ol/ol-notification'
import ErrorMessage from '@/features/share-project-modal/components/error-message'
import ProjectAccess from '@/features/share-project-modal/components/project-access'
import InvitedPeople from '@/features/share-project-modal/components/invited-people'
export default function ShareModalBody() {
type ShareModalBodyProps = {
isInvitedPeopleScreen: boolean
setIsInvitedPeopleScreen: React.Dispatch<React.SetStateAction<boolean>>
error?: string
}
export default function ShareModalBody({
isInvitedPeopleScreen,
setIsInvitedPeopleScreen,
error,
}: ShareModalBodyProps) {
const { project, features } = useProjectContext()
const { members, invites } = project || {}
const { isProjectOwner } = useEditorContext()
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
// whether the project has not reached the collaborator limit
const canAddCollaborators = useMemo(() => {
@@ -108,37 +124,62 @@ export default function ShareModalBody() {
) : (
<SendInvitesNotice />
)}
{isProjectOwner && <LinkSharing />}
<OwnerInfo />
{isSharingUpdatesEnabled ? (
<>
{error && (
<OLNotification
type="error"
content={<ErrorMessage error={error} />}
/>
)}
{isInvitedPeopleScreen || !isProjectOwner ? (
<InvitedPeople />
) : (
<ProjectAccess
setIsInvitedPeopleScreen={setIsInvitedPeopleScreen}
// adding +1 for the project owner
invitedPeopleCount={
sortedMembers.length + (invites || []).length + 1
}
/>
)}
</>
) : (
<>
{isProjectOwner && <LinkSharing />}
{sortedMembers.map(member =>
isProjectOwner ? (
<EditMember
key={member._id}
member={member}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
hasBeenDowngraded={Boolean(
member.pendingEditor || member.pendingReviewer
)}
canAddCollaborators={canAddCollaborators}
isReviewerOnFreeProject={
member.privileges === 'review' && !features.trackChanges
}
/>
) : (
<ViewMember key={member._id} member={member} />
)
<OwnerInfo />
{sortedMembers.map(member =>
isProjectOwner ? (
<EditMember
key={member._id}
member={member}
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
hasBeenDowngraded={Boolean(
member.pendingEditor || member.pendingReviewer
)}
canAddCollaborators={canAddCollaborators}
isReviewerOnFreeProject={
member.privileges === 'review' && !features.trackChanges
}
/>
) : (
<ViewMember key={member._id} member={member} />
)
)}
{(invites || []).map(invite => (
<Invite
key={invite._id}
invite={invite}
isProjectOwner={isProjectOwner}
/>
))}
</>
)}
{(invites || []).map(invite => (
<Invite
key={invite._id}
invite={invite}
isProjectOwner={isProjectOwner}
/>
))}
{!getMeta('ol-ExposedSettings').recaptchaDisabled?.invite && (
<RecaptchaConditions />
)}
@@ -1,6 +1,6 @@
import { useTranslation } from 'react-i18next'
import { useEditorContext } from '@/shared/context/editor-context'
import { lazy, Suspense } from 'react'
import { lazy, Suspense, useState } from 'react'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
import {
@@ -13,6 +13,11 @@ import {
import OLNotification from '@/shared/components/ol/ol-notification'
import OLButton from '@/shared/components/ol/ol-button'
import OLSpinner from '@/shared/components/ol/ol-spinner'
import MaterialIcon from '@/shared/components/material-icon'
import ErrorMessage from '@/features/share-project-modal/components/error-message'
import classNames from 'classnames'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import { useShareProjectContext } from '@/features/share-project-modal/components/share-project-modal'
const ReadOnlyTokenLink = lazy(() =>
import('./link-sharing').then(({ ReadOnlyTokenLink }) => ({
@@ -29,6 +34,7 @@ type ShareProjectModalContentProps = {
animation: boolean
inFlight: boolean
error: string | undefined
projectName?: string
}
export default function ShareProjectModalContent({
@@ -37,27 +43,59 @@ export default function ShareProjectModalContent({
animation,
inFlight,
error,
projectName,
}: ShareProjectModalContentProps) {
const { t } = useTranslation()
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
const [isInvitedPeopleScreen, setIsInvitedPeopleScreen] = useState(false)
const { successActionMessage } = useShareProjectContext()
const { isRestrictedTokenMember } = useEditorContext()
return (
<OLModal show={show} onHide={cancel} animation={animation}>
<OLModalHeader>
<OLModalTitle>{t('share_project')}</OLModalTitle>
<div className="d-flex flex-grow-1 justify-content-between">
{isSharingUpdatesEnabled && isInvitedPeopleScreen ? (
<OLButton
variant="ghost"
onClick={() => setIsInvitedPeopleScreen(false)}
leadingIcon="arrow_back_ios_new"
>
{t('back')}
</OLButton>
) : (
<OLModalTitle>
{isSharingUpdatesEnabled && projectName
? t('share_project_name', { projectName })
: t('share_project')}
</OLModalTitle>
)}
</div>
</OLModalHeader>
<OLModalBody className="modal-body-share modal-link-share">
<div className="container-fluid">
<OLModalBody
className={classNames('modal-body-share modal-link-share', {
'modal-redesign': isSharingUpdatesEnabled,
})}
>
<div
className={classNames({
'container-fluid': !isSharingUpdatesEnabled,
})}
>
<Suspense fallback={<FullSizeLoadingSpinner minHeight="15rem" />}>
{isRestrictedTokenMember ? (
<ReadOnlyTokenLink />
) : (
<ShareModalBody />
<ShareModalBody
isInvitedPeopleScreen={isInvitedPeopleScreen}
setIsInvitedPeopleScreen={setIsInvitedPeopleScreen}
error={error}
/>
)}
</Suspense>
{error && (
{!isSharingUpdatesEnabled && error && (
<OLNotification
type="error"
content={<ErrorMessage error={error} />}
@@ -68,12 +106,36 @@ export default function ShareProjectModalContent({
</OLModalBody>
<OLModalFooter>
<div className="me-auto">{inFlight && <OLSpinner size="sm" />}</div>
<div className="d-flex flex-grow-1 flex-wrap gap-2">
{isSharingUpdatesEnabled ? (
<>
{successActionMessage && (
<div className="ms-auto px-3 align-self-center">
<div
className="d-flex gap-3 align-items-center"
role="status"
aria-live="polite"
>
<MaterialIcon
unfilled
type="check_circle"
className="text-success"
/>
<span>{successActionMessage}</span>
</div>
</div>
)}
</>
) : (
inFlight && <OLSpinner size="sm" />
)}
</div>
<ClickableElementEnhancer
onClick={cancel}
as={OLButton}
variant="secondary"
variant={isSharingUpdatesEnabled ? 'ghost' : 'secondary'}
isLoading={isSharingUpdatesEnabled && inFlight}
disabled={inFlight}
>
{t('close')}
@@ -82,32 +144,3 @@ export default function ShareProjectModalContent({
</OLModal>
)
}
function ErrorMessage({ error }: Pick<ShareProjectModalContentProps, 'error'>) {
const { t } = useTranslation()
switch (error) {
case 'cannot_invite_non_user':
return <>{t('cannot_invite_non_user')}</>
case 'cannot_verify_user_not_robot':
return <>{t('cannot_verify_user_not_robot')}</>
case 'cannot_invite_self':
return <>{t('cannot_invite_self')}</>
case 'invalid_email':
return <>{t('invalid_email')}</>
case 'too_many_requests':
return <>{t('too_many_requests')}</>
case 'invite_expired':
return <>{t('invite_expired')}</>
case 'invite_resend_limit_hit':
return <>{t('invite_resend_limit_hit')}</>
default:
return <>{t('generic_something_went_wrong')}</>
}
}
@@ -0,0 +1,11 @@
const ShareProjectModalRow: React.FC<React.PropsWithChildren> = ({
children,
}) => {
return (
<div className="d-flex justify-content-between flex-wrap py-2">
{children}
</div>
)
}
export default ShareProjectModalRow
@@ -12,7 +12,13 @@ import { sendMB } from '@/infrastructure/event-tracking'
import { useEditorContext } from '@/shared/context/editor-context'
import customLocalStorage from '@/infrastructure/local-storage'
type ShareProjectContextValue = {
export type ProjectAccessType =
| 'linkSharing'
| 'onlyInvitedPeople'
| 'anyoneInXyzWithTheLink'
| 'anyoneWithTheLink'
export type ShareProjectContextValue = {
monitorRequest: <T extends Promise<unknown>>(request: () => T) => T
inFlight: boolean
setInFlight: React.Dispatch<
@@ -22,6 +28,14 @@ type ShareProjectContextValue = {
setError: React.Dispatch<
React.SetStateAction<ShareProjectContextValue['error']>
>
successActionMessage: string | undefined
setSuccessActionMessage: React.Dispatch<
React.SetStateAction<string | undefined>
>
projectAccess: ProjectAccessType | undefined
setProjectAccess: React.Dispatch<
React.SetStateAction<ProjectAccessType | undefined>
>
}
const SHOW_MODAL_COOLDOWN_PERIOD = 24 * 60 * 60 * 1000 // 24 hours
@@ -58,9 +72,28 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
const [inFlight, setInFlight] =
useState<ShareProjectContextValue['inFlight']>(false)
const [error, setError] = useState<ShareProjectContextValue['error']>()
const [projectAccess, setProjectAccess] = useState<
ProjectAccessType | undefined
>()
const [successActionMessage, setSuccessActionMessage] = useState<
string | undefined
>()
const { project, projectId } = useProjectContext()
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()
@@ -120,6 +153,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
const cancel = useCallback(() => {
if (!inFlight) {
handleHide()
setSuccessActionMessage(undefined)
}
}, [handleHide, inFlight])
@@ -157,6 +191,10 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
setInFlight,
error,
setError,
successActionMessage,
setSuccessActionMessage,
projectAccess,
setProjectAccess,
}}
>
<ShareProjectModalContent
@@ -165,6 +203,7 @@ const ShareProjectModal = React.memo(function ShareProjectModal({
error={error}
inFlight={inFlight}
show={show}
projectName={project.name}
/>
</ShareProjectContext.Provider>
)
@@ -1,9 +1,15 @@
import { ReactNode } from 'react'
export default function DropdownListItem({
className,
children,
}: {
className?: string
children: ReactNode
}) {
return <li role="none">{children}</li>
return (
<li role="none" className={className}>
{children}
</li>
)
}
@@ -294,3 +294,11 @@ $dropdown-item-min-height: 36px;
.dropdown-item-wide {
min-width: 264px;
}
.dropdown-toggle {
&.no-default-caret {
&::after {
content: none !important;
}
}
}
@@ -1,5 +1,5 @@
.modal-body-share {
h3 {
&:not(.modal-redesign) h3 {
border-bottom: 1px solid var(--neutral-30);
padding-bottom: calc(var(--line-height-03) / 4);
margin: 0;
@@ -159,6 +159,29 @@
}
}
}
&.modal-redesign {
.public-access-level {
margin-top: 0;
padding-top: 0;
padding-bottom: 0;
.access-token-display-area {
margin-top: 0;
}
}
.access-token-wrapper-title {
color: var(--content-primary) !important;
font-weight: 400;
}
.access-token {
code {
font-weight: 500;
}
}
}
}
.copy-button:focus-within {
@@ -199,12 +222,22 @@
.add-collaborator-controls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-03);
.select-wrapper {
max-width: 130px;
}
&:not(.add-collaborator-controls-legacy) .dropdown-menu {
left: auto;
right: 0;
}
}
.form-control-lg {
min-height: initial;
padding-top: calc(#{$input-padding-y-lg} - #{$input-border-width});
padding-bottom: calc(#{$input-padding-y-lg} - #{$input-border-width});
}
}
+15
View File
@@ -56,6 +56,7 @@
"access_denied": "Access Denied",
"access_edit_your_projects": "Access and edit your projects",
"access_levels_changed": "Access levels changed",
"access_updated": "Access updated.",
"account": "Account",
"account_already_managed": "Your account is already managed.",
"account_associated_with_x": "Your account is associated with __companyName__.",
@@ -197,8 +198,10 @@
"anonymous": "Anonymous",
"anyone": "Anyone",
"anyone_in_x": "Anyone in __groupName__",
"anyone_in_x_with_the_link": "Anyone in __companyName__ 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",
"app_on_x": "__appName__ on __social__",
"appearance": "Appearance",
"apply": "Apply ",
@@ -1461,6 +1464,7 @@
"make_owner": "Make owner",
"make_primary": "Make primary",
"make_private": "Make private",
"manage_access": "Manage access",
"manage_beta_program_membership": "Manage beta program membership",
"manage_email_preferences": "Manage email preferences",
"manage_files_from_your_dropbox_folder": "Manage files from your Dropbox folder",
@@ -1646,6 +1650,7 @@
"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",
@@ -1687,6 +1692,7 @@
"only_group_admin_or_managers_can_delete_your_account_7": "Only your group admin or group managers will be able to delete your account or change your account into an unmanaged account.",
"only_group_admin_or_managers_can_delete_your_account_9": "Once you have become a managed user, <0>you yourself cannot change it back to an unmanaged account</0>. <1>Learn more about managed Overleaf accounts.</1>",
"only_importer_can_refresh": "Only the person who originally imported this __provider__ file can refresh it.",
"only_invited_people": "Only invited people",
"only_project_owner_can_link_github": "Only the project owner can link this project to a GitHub repository. Please contact the project owner if you would like to set this up.",
"open_action_menu": "Open __name__ action menu",
"open_advanced_reference_search": "Open advanced reference search",
@@ -1914,6 +1920,7 @@
"professional": "Professional",
"progress_bar_percentage": "Progress bar from 0 to 100%",
"project": "project",
"project_access": "Project access",
"project_actions": "Project actions",
"project_approaching_file_limit": "This project is approaching the file limit",
"project_categories_tags": "Project categories and tags",
@@ -2048,6 +2055,8 @@
"remove_link": "Remove link",
"remove_manager": "Remove manager",
"remove_or_replace_figure": "Remove or replace figure",
"remove_original_links": "Remove original links",
"remove_original_sharing_links": "Remove original sharing links?",
"remove_secondary_email_addresses": "Remove any secondary email addresses associated with your account. <0>Remove them in account settings.</0>",
"remove_secondary_email_addresses_except_saml_email": "Remove all email addresses except <0>__institutionEmail__</0> from your Overleaf account. <1>Remove them in account settings.</1>",
"remove_sso_login_option": "Remove the SSO login option for your users.",
@@ -2272,6 +2281,7 @@
"share": "Share",
"share_feedback": "Share feedback",
"share_project": "Share Project",
"share_project_name": "Share __projectName__",
"shared_with_you": "Shared with you",
"sharelatex_beta_program": "__appName__ beta program",
"sharing_permissions": "Sharing permissions",
@@ -2587,6 +2597,8 @@
"this_action_cannot_be_undone": "This action cannot be undone.",
"this_action_will_also_disable_domain_capture": "This action will also disable domain capture.",
"this_address_will_be_shown_on_the_invoice": "This address will be shown on the invoice",
"this_change_will_permanently_remove_your_original_links_you_can_still_share_new_link": "This change will permanently remove your original sharing links. You can still share a new link to the project when you choose a relevant sharing option.",
"this_change_will_permanently_remove_your_original_links_you_need_to_share_the_new_link": "This change will permanently remove your original sharing links. Youll need to share the new link instead.",
"this_could_be_because_we_cant_support_some_elements_of_the_table": "This could be because we cant yet support some elements of the table in the table preview. Or there may be an error in the tables LaTeX code.",
"this_field_is_required": "This field is required",
"this_grants_access_to_features_2": "This grants you access to <0>__appName__</0> <0>__featureType__</0> features.",
@@ -2881,6 +2893,7 @@
"verify_email_address_before_enabling_managed_users": "You need to verify your email address before enabling managed users.",
"verify_your_email_address": "Verify your email address",
"verifying_domain": "Verifying domain",
"via_sharing_links_legacy": "Via sharing links (legacy)",
"via_support": "via support",
"view": "View",
"view_all": "View all",
@@ -2975,6 +2988,8 @@
"x_changes_in": "__count__ change in",
"x_changes_in_plural": "__count__ changes in",
"x_libraries_accessed_in_this_project": "__provider__ libraries accessed in this project",
"x_people_invited": "__count__ person invited",
"x_people_invited_plural": "__count__ people invited",
"x_price_for_first_month": "<0>__price__</0> for your first month",
"x_price_for_first_year": "<0>__price__</0> for your first year",
"x_price_for_y_months": "<0>__price__</0> for your first __discountMonths__ months",