Просмотр исходного кода

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

[web] Share modal redesign base layout

GitOrigin-RevId: 0f011d03762c6669a0fd3b1fc2af32c9026c7ea4
ilkin-overleaf 3 месяцев назад
Родитель
Сommit
816f8c45eb
18 измененных файлов с 747 добавлено и 122 удалено
  1. 1 0
      services/web/app/src/Features/Project/ProjectController.mjs
  2. 15 0
      services/web/frontend/extracted-translations.json
  3. BIN
      services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2
  4. 7 0
      services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs
  5. 33 0
      services/web/frontend/js/features/share-project-modal/components/error-message.tsx
  6. 7 0
      services/web/frontend/js/features/share-project-modal/components/invited-people.tsx
  7. 77 50
      services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx
  8. 280 0
      services/web/frontend/js/features/share-project-modal/components/project-access.tsx
  9. 57 0
      services/web/frontend/js/features/share-project-modal/components/remove-sharing-links-modal.tsx
  10. 13 1
      services/web/frontend/js/features/share-project-modal/components/send-invites.tsx
  11. 71 30
      services/web/frontend/js/features/share-project-modal/components/share-modal-body.tsx
  12. 70 37
      services/web/frontend/js/features/share-project-modal/components/share-project-modal-content.tsx
  13. 11 0
      services/web/frontend/js/features/share-project-modal/components/share-project-modal-row.tsx
  14. 40 1
      services/web/frontend/js/features/share-project-modal/components/share-project-modal.tsx
  15. 7 1
      services/web/frontend/js/shared/components/dropdown/dropdown-list-item.tsx
  16. 8 0
      services/web/frontend/stylesheets/components/dropdown-menu.scss
  17. 35 2
      services/web/frontend/stylesheets/pages/editor/share.scss
  18. 15 0
      services/web/locales/en.json

+ 1 - 0
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 =>

+ 15 - 0
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": "",

BIN
services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2


+ 7 - 0
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',

+ 33 - 0
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<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

+ 7 - 0
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

+ 77 - 50
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 <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)
+export function ReadAndWriteTokenLinks() {
+  const tokens = useProjectTokens()
 
-  const { signal } = useAbortController()
+  return (
+    <OLRow className="public-access-level">
+      <OLCol className="access-token-display-area">
+        <AccessTokenEditDisplayArea tokens={tokens} />
+        <AccessTokenViewDisplayArea tokens={tokens} />
+      </OLCol>
+    </OLRow>
+  )
+}
 
-  useEffect(() => {
-    getJSON(`/project/${projectId}/tokens`, { signal })
-      .then(data => setTokens(data))
-      .catch(debugConsole.error)
-  }, [projectId, signal])
+export function ReadOnlyTokenLink() {
+  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>
+        <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>
+  )
+}

+ 280 - 0
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<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

+ 57 - 0
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 (
+    <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

+ 13 - 1
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 (
-    <OLRow className="invite-controls">
+    <OLRow
+      className={classnames('invite-controls', {
+        'pb-3': isSharingUpdatesEnabled,
+      })}
+    >
       {hasExceededCollaboratorLimit && !haveAnyEditorsBeenDowngraded && (
         <AddCollaboratorsUpgrade />
       )}

+ 71 - 30
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'
+
+type ShareModalBodyProps = {
+  isInvitedPeopleScreen: boolean
+  setIsInvitedPeopleScreen: React.Dispatch<React.SetStateAction<boolean>>
+  error?: string
+}
 
-export default function ShareModalBody() {
+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,36 +124,61 @@ export default function ShareModalBody() {
       ) : (
         <SendInvitesNotice />
       )}
-      {isProjectOwner && <LinkSharing />}
-
-      <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}
-        />
-      ))}
+      {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 />}
+
+          <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}
+            />
+          ))}
+        </>
+      )}
 
       {!getMeta('ol-ExposedSettings').recaptchaDisabled?.invite && (
         <RecaptchaConditions />

+ 70 - 37
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 (
     <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')}</>
-  }
-}

+ 11 - 0
services/web/frontend/js/features/share-project-modal/components/share-project-modal-row.tsx

@@ -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

+ 40 - 1
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: <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>
   )

+ 7 - 1
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 <li role="none">{children}</li>
+  return (
+    <li role="none" className={className}>
+      {children}
+    </li>
+  )
 }

+ 8 - 0
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;
+    }
+  }
+}

+ 35 - 2
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});
   }
 }

+ 15 - 0
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</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. 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> <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",