diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 50b3500556..75bb479fa1 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -481,6 +481,7 @@ const _ProjectController = { 'editor-tabs', 'overleaf-code', 'export-docx', + 'sharing-updates', ].filter(Boolean) const getUserValues = async userId => diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 5d02d14091..457c09d5fc 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 index 4ca1d4db78..ae0890e9eb 100644 Binary files a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 and b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 differ diff --git a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs index c5d8120aeb..e503509ee9 100644 --- a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs +++ b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs @@ -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', diff --git a/services/web/frontend/js/features/share-project-modal/components/error-message.tsx b/services/web/frontend/js/features/share-project-modal/components/error-message.tsx new file mode 100644 index 0000000000..281ca0a03e --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/error-message.tsx @@ -0,0 +1,33 @@ +import { useTranslation } from 'react-i18next' +import { ShareProjectContextValue } from '@/features/share-project-modal/components/share-project-modal' + +function ErrorMessage({ error }: Pick) { + 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 diff --git a/services/web/frontend/js/features/share-project-modal/components/invited-people.tsx b/services/web/frontend/js/features/share-project-modal/components/invited-people.tsx new file mode 100644 index 0000000000..9d1308f3b6 --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/invited-people.tsx @@ -0,0 +1,7 @@ +type InvitedPeopleProps = object + +function InvitedPeople(_props: InvitedPeopleProps) { + return null +} + +export default InvitedPeople 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 a5596cd418..3ec1e70a25 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 @@ -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 + } + 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(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(null) - - const { signal } = useAbortController() - - useEffect(() => { - getJSON(`/project/${projectId}/tokens`, { signal }) - .then(data => setTokens(data)) - .catch(debugConsole.error) - }, [projectId, signal]) + const tokens = useProjectTokens() return ( @@ -191,24 +204,8 @@ function TokenBasedSharing({ {showLinks && ( -
- {t('anyone_with_link_can_edit')} - -
-
- {t('anyone_with_link_can_view')} - -
+ +
)}
@@ -249,32 +246,26 @@ function LegacySharing({ ) } -export function ReadOnlyTokenLink() { - const { t } = useTranslation() - const { projectId } = useProjectContext() - - const [tokens, setTokens] = useState(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 ( -
- {t('anyone_with_link_can_view')} - -
+ + +
+
+ ) +} + +export function ReadOnlyTokenLink() { + const tokens = useProjectTokens() + + return ( + + + ) @@ -336,3 +327,39 @@ function LinkSharingInfo() { ) } + +function AccessTokenEditDisplayArea({ tokens }: { tokens: Tokens | null }) { + const { t } = useTranslation() + + return ( +
+ + {t('anyone_with_link_can_edit')} + + +
+ ) +} + +function AccessTokenViewDisplayArea({ tokens }: { tokens: Tokens | null }) { + const { t } = useTranslation() + + return ( +
+ + {t('anyone_with_link_can_view')} + + +
+ ) +} 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 new file mode 100644 index 0000000000..4be98572ab --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/project-access.tsx @@ -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> + invitedPeopleCount: number +} + +export type PendingAccessType = Exclude + +function ProjectAccess({ + setIsInvitedPeopleScreen, + invitedPeopleCount, +}: ProjectAccessProps) { + const { t } = useTranslation() + const [pendingAccess, setPendingAccess] = useState( + null + ) + // TODO set initial state + const [privileges, setPrivileges] = + useState>('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) => { + 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 ( + <> +

{t('project_access')}

+ +
+ +
+ {t('x_people_invited', { count: invitedPeopleCount })} +
+
+ setIsInvitedPeopleScreen(true)} + > + {t('manage_access')} + +
+ {projectAccess && ( + +
+ {projectAccess === 'linkSharing' && } + {projectAccess === 'onlyInvitedPeople' && ( + + )} + {projectAccess === 'anyoneInXyzWithTheLink' && ( + + )} + {projectAccess === 'anyoneWithTheLink' && ( + + )} + + + {getProjectAccessDropdownToggleText()} + + + + {projectAccess === 'linkSharing' && ( + <> + + } + trailingIcon={ + projectAccess === 'linkSharing' ? 'check' : undefined + } + active={projectAccess === 'linkSharing'} + > + {t('via_sharing_links_legacy')} + + + + + )} + + } + trailingIcon={ + projectAccess === 'onlyInvitedPeople' + ? 'check' + : undefined + } + active={projectAccess === 'onlyInvitedPeople'} + > + {t('only_invited_people')} + + + + } + trailingIcon={ + projectAccess === 'anyoneInXyzWithTheLink' + ? 'check' + : undefined + } + active={projectAccess === 'anyoneInXyzWithTheLink'} + > + {t('anyone_in_x_with_the_link', { companyName })} + + + + } + trailingIcon={ + projectAccess === 'anyoneWithTheLink' + ? 'check' + : undefined + } + active={projectAccess === 'anyoneWithTheLink'} + > + {t('anyone_with_the_link')} + + + + + + + + + + {pendingAccess && ( + setPendingAccess(null)} + onConfirm={() => handleAccessChange(pendingAccess)} + /> + )} +
+ {projectAccess !== 'linkSharing' && ( + + + + + + + } + active={privileges === 'readAndWrite'} + trailingIcon={ + privileges === 'readAndWrite' ? 'check' : undefined + } + > + {t('editor')} + + } + active={privileges === 'review'} + trailingIcon={privileges === 'review' ? 'check' : undefined} + > + {t('reviewer')} + + } + active={privileges === 'readOnly'} + trailingIcon={privileges === 'readOnly' ? 'check' : undefined} + > + {t('viewer')} + + + + )} +
+ )} + + {isProjectOwner && } + + ) +} + +export default ProjectAccess diff --git a/services/web/frontend/js/features/share-project-modal/components/remove-sharing-links-modal.tsx b/services/web/frontend/js/features/share-project-modal/components/remove-sharing-links-modal.tsx new file mode 100644 index 0000000000..0eb1489a14 --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/remove-sharing-links-modal.tsx @@ -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 ( + + + {t('remove_original_sharing_links')} + + {confirmationModalBodyText} + + + {t('cancel')} + + + {t('remove_original_links')} + + + + ) +} + +export default RemoveSharingLinksModal diff --git a/services/web/frontend/js/features/share-project-modal/components/send-invites.tsx b/services/web/frontend/js/features/share-project-modal/components/send-invites.tsx index 41e831af2d..089544ddf0 100644 --- a/services/web/frontend/js/features/share-project-modal/components/send-invites.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/send-invites.tsx @@ -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 ( - + {hasExceededCollaboratorLimit && !haveAnyEditorsBeenDowngraded && ( )} diff --git a/services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx b/services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx index 4f74d1d5b5..9a70aa1f22 100644 --- a/services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx @@ -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> + 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() { ) : ( )} - {isProjectOwner && } - + {isSharingUpdatesEnabled ? ( + <> + {error && ( + } + /> + )} + {isInvitedPeopleScreen || !isProjectOwner ? ( + + ) : ( + + )} + + ) : ( + <> + {isProjectOwner && } - {sortedMembers.map(member => - isProjectOwner ? ( - - ) : ( - - ) + + + {sortedMembers.map(member => + isProjectOwner ? ( + + ) : ( + + ) + )} + + {(invites || []).map(invite => ( + + ))} + )} - {(invites || []).map(invite => ( - - ))} - {!getMeta('ol-ExposedSettings').recaptchaDisabled?.invite && ( )} diff --git a/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx b/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx index 7ef3f601b2..a45b21f3c4 100644 --- a/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx @@ -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 ( - {t('share_project')} +
+ {isSharingUpdatesEnabled && isInvitedPeopleScreen ? ( + setIsInvitedPeopleScreen(false)} + leadingIcon="arrow_back_ios_new" + > + {t('back')} + + ) : ( + + {isSharingUpdatesEnabled && projectName + ? t('share_project_name', { projectName }) + : t('share_project')} + + )} +
- -
+ +
}> {isRestrictedTokenMember ? ( ) : ( - + )} - {error && ( + {!isSharingUpdatesEnabled && error && ( } @@ -68,12 +106,36 @@ export default function ShareProjectModalContent({ -
{inFlight && }
+
+ {isSharingUpdatesEnabled ? ( + <> + {successActionMessage && ( +
+
+ + {successActionMessage} +
+
+ )} + + ) : ( + inFlight && + )} +
{t('close')} @@ -82,32 +144,3 @@ export default function ShareProjectModalContent({ ) } - -function ErrorMessage({ error }: Pick) { - 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')} - } -} diff --git a/services/web/frontend/js/features/share-project-modal/components/share-project-modal-row.tsx b/services/web/frontend/js/features/share-project-modal/components/share-project-modal-row.tsx new file mode 100644 index 0000000000..ff1ed50ead --- /dev/null +++ b/services/web/frontend/js/features/share-project-modal/components/share-project-modal-row.tsx @@ -0,0 +1,11 @@ +const ShareProjectModalRow: React.FC = ({ + children, +}) => { + return ( +
+ {children} +
+ ) +} + +export default ShareProjectModalRow diff --git a/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx b/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx index 6dabc9f9d8..cade5e8077 100644 --- a/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx @@ -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: >(request: () => T) => T inFlight: boolean setInFlight: React.Dispatch< @@ -22,6 +28,14 @@ type ShareProjectContextValue = { setError: React.Dispatch< React.SetStateAction > + successActionMessage: string | undefined + setSuccessActionMessage: React.Dispatch< + React.SetStateAction + > + projectAccess: ProjectAccessType | undefined + setProjectAccess: React.Dispatch< + React.SetStateAction + > } 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(false) const [error, setError] = useState() + 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, }} > ) diff --git a/services/web/frontend/js/shared/components/dropdown/dropdown-list-item.tsx b/services/web/frontend/js/shared/components/dropdown/dropdown-list-item.tsx index 3b492faca3..f5ab85202a 100644 --- a/services/web/frontend/js/shared/components/dropdown/dropdown-list-item.tsx +++ b/services/web/frontend/js/shared/components/dropdown/dropdown-list-item.tsx @@ -1,9 +1,15 @@ import { ReactNode } from 'react' export default function DropdownListItem({ + className, children, }: { + className?: string children: ReactNode }) { - return
  • {children}
  • + return ( +
  • + {children} +
  • + ) } diff --git a/services/web/frontend/stylesheets/components/dropdown-menu.scss b/services/web/frontend/stylesheets/components/dropdown-menu.scss index 6c53042d21..aa2161375a 100644 --- a/services/web/frontend/stylesheets/components/dropdown-menu.scss +++ b/services/web/frontend/stylesheets/components/dropdown-menu.scss @@ -294,3 +294,11 @@ $dropdown-item-min-height: 36px; .dropdown-item-wide { min-width: 264px; } + +.dropdown-toggle { + &.no-default-caret { + &::after { + content: none !important; + } + } +} diff --git a/services/web/frontend/stylesheets/pages/editor/share.scss b/services/web/frontend/stylesheets/pages/editor/share.scss index abd0fb7359..f9acbb6a68 100644 --- a/services/web/frontend/stylesheets/pages/editor/share.scss +++ b/services/web/frontend/stylesheets/pages/editor/share.scss @@ -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}); } } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index c84fb06ade..f03b253775 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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 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", @@ -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. <1>Learn more about managed Overleaf accounts.", "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.", "remove_secondary_email_addresses_except_saml_email": "Remove all email addresses except <0>__institutionEmail__ from your Overleaf account. <1>Remove them in account settings.", "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. You’ll need to share the new link instead.", "this_could_be_because_we_cant_support_some_elements_of_the_table": "This could be because we can’t yet support some elements of the table in the table preview. Or there may be an error in the table’s LaTeX code.", "this_field_is_required": "This field is required", "this_grants_access_to_features_2": "This grants you access to <0>__appName__ <0>__featureType__ 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__ for your first month", "x_price_for_first_year": "<0>__price__ for your first year", "x_price_for_y_months": "<0>__price__ for your first __discountMonths__ months",