Ver código fonte

Merge pull request #33065 from overleaf/ii-share-modal-send-invites

[web] Add send invites input and role selection to share modal

GitOrigin-RevId: f43654e1ca0d8000b2327f1f398fd062ef1b74e4
ilkin-overleaf 3 meses atrás
pai
commit
5727643852

+ 4 - 0
services/web/frontend/extracted-translations.json

@@ -576,6 +576,7 @@
   "email_does_not_belong_to_university": "",
   "email_does_not_belong_to_university": "",
   "email_limit_reached": "",
   "email_limit_reached": "",
   "email_link_expired": "",
   "email_link_expired": "",
+  "email_must_be_a_valid_format": "",
   "email_must_be_linked_to_institution": "",
   "email_must_be_linked_to_institution": "",
   "email_notifications_are_currently_in_beta": "",
   "email_notifications_are_currently_in_beta": "",
   "email_or_password_wrong_try_again": "",
   "email_or_password_wrong_try_again": "",
@@ -596,6 +597,7 @@
   "end_time_utc": "",
   "end_time_utc": "",
   "ensure_recover_account": "",
   "ensure_recover_account": "",
   "enter_any_size_including_units_or_valid_latex_command": "",
   "enter_any_size_including_units_or_valid_latex_command": "",
+  "enter_emails_separated_by_commas": "",
   "enter_manually": "",
   "enter_manually": "",
   "enter_tax_id_number": "",
   "enter_tax_id_number": "",
   "enter_the_code": "",
   "enter_the_code": "",
@@ -963,6 +965,7 @@
   "invalid_upload_request": "",
   "invalid_upload_request": "",
   "invert_pdf_preview_colors": "",
   "invert_pdf_preview_colors": "",
   "invert_pdf_preview_colors_when_in_dark_mode": "",
   "invert_pdf_preview_colors_when_in_dark_mode": "",
+  "invitations_sent": "",
   "invite": "",
   "invite": "",
   "invite_expired": "",
   "invite_expired": "",
   "invite_group_members": "",
   "invite_group_members": "",
@@ -1275,6 +1278,7 @@
   "on": "",
   "on": "",
   "on_free_plan_upgrade_to_access_features": "",
   "on_free_plan_upgrade_to_access_features": "",
   "one_step_away_from_professional_features": "",
   "one_step_away_from_professional_features": "",
+  "only_add_people_who_dont_yet_have_access": "",
   "only_group_admin_or_managers_can_delete_your_account_1": "",
   "only_group_admin_or_managers_can_delete_your_account_1": "",
   "only_group_admin_or_managers_can_delete_your_account_10": "",
   "only_group_admin_or_managers_can_delete_your_account_10": "",
   "only_group_admin_or_managers_can_delete_your_account_3": "",
   "only_group_admin_or_managers_can_delete_your_account_3": "",

+ 294 - 0
services/web/frontend/js/features/share-project-modal/components/add-collaborators-select.tsx

@@ -0,0 +1,294 @@
+import { useMemo, useCallback, useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useMultipleSelection } from 'downshift'
+import { Select } from '@/shared/components/select'
+import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
+import OLButton from '@/shared/components/ol/ol-button'
+import { PermissionsLevel } from '@/features/ide-react/types/permissions'
+import { useProjectContext } from '@/shared/context/project-context'
+import {
+  resendInvite,
+  sendInvite,
+} from '@/features/share-project-modal/utils/api'
+import { ContactItem } from '@/features/share-project-modal/components/select-collaborators'
+import { useShareProjectContext } from '@/features/share-project-modal/components/share-project-modal'
+import useIsMounted from '@/shared/hooks/use-is-mounted'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
+import { sendMB } from '@/infrastructure/event-tracking'
+import MaterialIcon, {
+  AvailableUnfilledIcon,
+} from '@/shared/components/material-icon'
+import { isValidEmail } from '@/shared/utils/email'
+
+type AddCollaboratorsSelectProps = {
+  readOnly?: boolean
+  multipleSelectionProps: ReturnType<typeof useMultipleSelection<ContactItem>>
+  currentMemberEmails: string[]
+  inputValue?: string
+  onInviteSuccess?: () => void
+  hasErrors?: boolean
+}
+
+function AddCollaboratorsSelect({
+  readOnly,
+  multipleSelectionProps,
+  currentMemberEmails,
+  inputValue,
+  onInviteSuccess,
+  hasErrors,
+}: AddCollaboratorsSelectProps) {
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
+  const { t } = useTranslation()
+  const { features } = useProjectContext()
+  const [privileges, setPrivileges] = useState<PermissionsLevel>('readAndWrite')
+
+  const isMounted = useIsMounted()
+
+  const { setInFlight, setError } = useShareProjectContext()
+  const [isSubmitting, setIsSubmitting] = useState(false)
+  const { projectId, project, updateProject } = useProjectContext()
+  const { members, invites } = project || {}
+
+  const privilegeOptions = useMemo(() => {
+    const options: {
+      key: PermissionsLevel
+      label: string
+      description?: string | null
+      icon: AvailableUnfilledIcon
+    }[] = [
+      {
+        key: 'readAndWrite',
+        label: t('editor'),
+        icon: 'edit',
+      },
+    ]
+
+    if (features.trackChangesVisible) {
+      options.push({
+        key: 'review',
+        label: t('reviewer'),
+        description: !features.trackChanges
+          ? t('comment_only_upgrade_for_track_changes')
+          : null,
+        icon: 'mode_comment',
+      })
+    }
+
+    options.push({
+      key: 'readOnly',
+      label: t('viewer'),
+      icon: 'visibility',
+    })
+
+    return options
+  }, [features.trackChanges, features.trackChangesVisible, t])
+
+  const { reset, selectedItems } = multipleSelectionProps
+
+  useEffect(() => {
+    if (readOnly && privileges !== 'readOnly') {
+      setPrivileges('readOnly')
+    }
+  }, [privileges, readOnly])
+
+  const handleSubmit = useCallback(async () => {
+    if (!selectedItems.length) {
+      return
+    }
+
+    // reset the selected items
+    reset()
+
+    setError(undefined)
+    if (isSharingUpdatesEnabled) {
+      setIsSubmitting(true)
+    } else {
+      setInFlight(true)
+    }
+
+    let hasError = false
+    let hasInvited = false
+
+    for (const contact of selectedItems) {
+      // unmounting means can't add any more collaborators
+      if (!isMounted.current) {
+        break
+      }
+
+      const email = contact.type === 'user' ? contact.email : contact.display
+      const normalisedEmail = email.toLowerCase()
+
+      if (currentMemberEmails.includes(normalisedEmail)) {
+        continue
+      }
+
+      hasInvited = true
+
+      let data
+
+      try {
+        const invite = invites?.find(invite => invite.email === normalisedEmail)
+
+        if (invite) {
+          data = await resendInvite(projectId, invite)
+        } else {
+          data = await sendInvite(projectId, email, privileges)
+        }
+
+        const role = data?.invite?.privileges
+        const membersAndInvites = (members || []).concat(invites || [])
+        const previousEditorsAmount = membersAndInvites.filter(
+          member => member.privileges === 'readAndWrite'
+        ).length
+        const previousReviewersAmount = membersAndInvites.filter(
+          member => member.privileges === 'review'
+        ).length
+        const previousViewersAmount = membersAndInvites.filter(
+          member => member.privileges === 'readOnly'
+        ).length
+
+        sendMB('collaborator-invited', {
+          project_id: projectId,
+          // invitation is only populated on successful invite, meaning that for paywall and other cases this will be null
+          successful_invite: !!data.invite,
+          users_updated: !!(data.users || data.user),
+          current_collaborators_amount: members?.length || 0,
+          current_invites_amount: invites?.length || 0,
+          role,
+          previousEditorsAmount,
+          previousReviewersAmount,
+          previousViewersAmount,
+          newEditorsAmount:
+            role === 'readAndWrite'
+              ? previousEditorsAmount + 1
+              : previousEditorsAmount,
+          newReviewersAmount:
+            role === 'review'
+              ? previousReviewersAmount + 1
+              : previousReviewersAmount,
+          newViewersAmount:
+            role === 'readOnly'
+              ? previousViewersAmount + 1
+              : previousViewersAmount,
+        })
+      } catch (error: any) {
+        hasError = true
+        if (isSharingUpdatesEnabled) {
+          setIsSubmitting(false)
+        } else {
+          setInFlight(false)
+        }
+        setError(
+          error.data?.errorReason ||
+            (error.response?.status === 429
+              ? 'too_many_requests'
+              : 'generic_something_went_wrong')
+        )
+        break
+      }
+
+      if (data.error) {
+        hasError = true
+        setError(data.error)
+        if (isSharingUpdatesEnabled) {
+          setIsSubmitting(false)
+        } else {
+          setInFlight(false)
+        }
+      } else if (data.invite) {
+        updateProject({
+          invites: invites?.concat(data.invite) || [data.invite],
+        })
+      } else if (data.users) {
+        updateProject({
+          members: members?.concat(data.users) || data.users,
+        })
+      } else if (data.user) {
+        updateProject({
+          members: members?.concat(data.user) || [data.user],
+        })
+      }
+
+      // wait for a short time, so canAddCollaborators has time to update with new collaborator information
+      await new Promise(resolve => setTimeout(resolve, 100))
+    }
+
+    if (isSharingUpdatesEnabled) {
+      setIsSubmitting(false)
+    } else {
+      setInFlight(false)
+    }
+
+    if (!hasError && hasInvited) {
+      onInviteSuccess?.()
+    }
+  }, [
+    currentMemberEmails,
+    invites,
+    isMounted,
+    isSharingUpdatesEnabled,
+    members,
+    onInviteSuccess,
+    privileges,
+    projectId,
+    reset,
+    selectedItems,
+    setError,
+    setInFlight,
+    updateProject,
+  ])
+
+  const canInvite =
+    !hasErrors &&
+    ((inputValue &&
+      isValidEmail(inputValue) &&
+      !currentMemberEmails.includes(inputValue.toLowerCase())) ||
+      selectedItems.length > 0)
+
+  const showPermissionsSelect = Boolean(inputValue) || selectedItems.length > 0
+
+  const permissionComponent = (
+    <Select
+      dataTestId="add-collaborator-select"
+      items={privilegeOptions}
+      itemToKey={item => item.key}
+      itemToString={item => item?.label || ''}
+      itemToSubtitle={item => item?.description || ''}
+      itemToDisabled={item => !!(readOnly && item?.key !== 'readOnly')}
+      itemToLeadingIcon={item =>
+        isSharingUpdatesEnabled &&
+        item && <MaterialIcon type={item.icon} unfilled />
+      }
+      selected={privilegeOptions.find(option => option.key === privileges)}
+      onSelectedItemChanged={item => {
+        if (item) {
+          setPrivileges(item.key)
+        }
+      }}
+      selectedIcon={isSharingUpdatesEnabled}
+      size={isSharingUpdatesEnabled ? 'lg' : undefined}
+    />
+  )
+
+  return (
+    <>
+      {isSharingUpdatesEnabled
+        ? showPermissionsSelect
+          ? permissionComponent
+          : null
+        : permissionComponent}
+      <ClickableElementEnhancer
+        as={OLButton}
+        onClick={handleSubmit}
+        variant="primary"
+        size={isSharingUpdatesEnabled ? 'lg' : undefined}
+        disabled={isSharingUpdatesEnabled && !canInvite}
+        isLoading={isSharingUpdatesEnabled && isSubmitting}
+      >
+        {t('invite')}
+      </ClickableElementEnhancer>
+    </>
+  )
+}
+
+export default AddCollaboratorsSelect

+ 38 - 208
services/web/frontend/js/features/share-project-modal/components/add-collaborators.tsx

@@ -1,37 +1,26 @@
-import { useEffect, useState, useMemo, useCallback } from 'react'
+import { useMemo } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import { useMultipleSelection } from 'downshift'
 import { useMultipleSelection } from 'downshift'
-import { useShareProjectContext } from './share-project-modal'
 import SelectCollaborators, { ContactItem } from './select-collaborators'
 import SelectCollaborators, { ContactItem } from './select-collaborators'
-import { resendInvite, sendInvite } from '../utils/api'
 import { useUserContacts } from '../hooks/use-user-contacts'
 import { useUserContacts } from '../hooks/use-user-contacts'
-import useIsMounted from '@/shared/hooks/use-is-mounted'
 import { useProjectContext } from '@/shared/context/project-context'
 import { useProjectContext } from '@/shared/context/project-context'
-import { sendMB } from '@/infrastructure/event-tracking'
-import ClickableElementEnhancer from '@/shared/components/clickable-element-enhancer'
 import OLForm from '@/shared/components/ol/ol-form'
 import OLForm from '@/shared/components/ol/ol-form'
-import OLFormGroup from '@/shared/components/ol/ol-form-group'
-import { Select } from '@/shared/components/select'
-import OLButton from '@/shared/components/ol/ol-button'
-import { PermissionsLevel } from '@/features/ide-react/types/permissions'
 import OLFormText from '@/shared/components/ol/ol-form-text'
 import OLFormText from '@/shared/components/ol/ol-form-text'
+import OLFormGroup from '@/shared/components/ol/ol-form-group'
+import AddCollaboratorsSelect from '@/features/share-project-modal/components/add-collaborators-select'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 
 export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
 export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
-  const [privileges, setPrivileges] = useState<PermissionsLevel>('readAndWrite')
-
-  const isMounted = useIsMounted()
-
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
   const { data: contacts } = useUserContacts()
   const { data: contacts } = useUserContacts()
 
 
   const { t } = useTranslation()
   const { t } = useTranslation()
 
 
-  const { setInFlight, setError } = useShareProjectContext()
-
-  const { projectId, project, features, updateProject } = useProjectContext()
-  const { members, invites } = project || {}
+  const { project } = useProjectContext()
+  const { members } = project || {}
 
 
   const currentMemberEmails = useMemo(
   const currentMemberEmails = useMemo(
-    () => (members || []).map(member => member.email).sort(),
+    () => (members || []).map(member => member.email.toLowerCase()).sort(),
     [members]
     [members]
   )
   )
 
 
@@ -41,7 +30,7 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
     }
     }
 
 
     return contacts.filter(
     return contacts.filter(
-      contact => !currentMemberEmails.includes(contact.email)
+      contact => !currentMemberEmails.includes(contact.email.toLowerCase())
     )
     )
   }, [contacts, currentMemberEmails])
   }, [contacts, currentMemberEmails])
 
 
@@ -50,201 +39,42 @@ export default function AddCollaborators({ readOnly }: { readOnly?: boolean }) {
     initialSelectedItems: [],
     initialSelectedItems: [],
   })
   })
 
 
-  const { reset, selectedItems } = multipleSelectionProps
-
-  useEffect(() => {
-    if (readOnly && privileges !== 'readOnly') {
-      setPrivileges('readOnly')
-    }
-  }, [privileges, readOnly])
-
-  const handleSubmit = useCallback(async () => {
-    if (!selectedItems.length) {
-      return
-    }
-
-    // reset the selected items
-    reset()
-
-    setError(undefined)
-    setInFlight(true)
-
-    for (const contact of selectedItems) {
-      // unmounting means can't add any more collaborators
-      if (!isMounted.current) {
-        break
-      }
-
-      const email = contact.type === 'user' ? contact.email : contact.display
-      const normalisedEmail = email.toLowerCase()
-
-      if (currentMemberEmails.includes(normalisedEmail)) {
-        continue
-      }
-
-      let data
-
-      try {
-        const invite = invites?.find(invite => invite.email === normalisedEmail)
-
-        if (invite) {
-          data = await resendInvite(projectId, invite)
-        } else {
-          data = await sendInvite(projectId, email, privileges)
-        }
-
-        const role = data?.invite?.privileges
-        const membersAndInvites = (members || []).concat(invites || [])
-        const previousEditorsAmount = membersAndInvites.filter(
-          member => member.privileges === 'readAndWrite'
-        ).length
-        const previousReviewersAmount = membersAndInvites.filter(
-          member => member.privileges === 'review'
-        ).length
-        const previousViewersAmount = membersAndInvites.filter(
-          member => member.privileges === 'readOnly'
-        ).length
-
-        sendMB('collaborator-invited', {
-          project_id: projectId,
-          // invitation is only populated on successful invite, meaning that for paywall and other cases this will be null
-          successful_invite: !!data.invite,
-          users_updated: !!(data.users || data.user),
-          current_collaborators_amount: members?.length || 0,
-          current_invites_amount: invites?.length || 0,
-          role,
-          previousEditorsAmount,
-          previousReviewersAmount,
-          previousViewersAmount,
-          newEditorsAmount:
-            role === 'readAndWrite'
-              ? previousEditorsAmount + 1
-              : previousEditorsAmount,
-          newReviewersAmount:
-            role === 'review'
-              ? previousReviewersAmount + 1
-              : previousReviewersAmount,
-          newViewersAmount:
-            role === 'readOnly'
-              ? previousViewersAmount + 1
-              : previousViewersAmount,
-        })
-      } catch (error: any) {
-        setInFlight(false)
-        setError(
-          error.data?.errorReason ||
-            (error.response?.status === 429
-              ? 'too_many_requests'
-              : 'generic_something_went_wrong')
-        )
-        break
-      }
-
-      if (data.error) {
-        setError(data.error)
-        setInFlight(false)
-      } else if (data.invite) {
-        updateProject({
-          invites: invites?.concat(data.invite) || [data.invite],
-        })
-      } else if (data.users) {
-        updateProject({
-          members: members?.concat(data.users) || data.users,
-        })
-      } else if (data.user) {
-        updateProject({
-          members: members?.concat(data.user) || [data.user],
-        })
-      }
-
-      // wait for a short time, so canAddCollaborators has time to update with new collaborator information
-      await new Promise(resolve => setTimeout(resolve, 100))
-    }
-
-    setInFlight(false)
-  }, [
-    currentMemberEmails,
-    invites,
-    isMounted,
-    members,
-    privileges,
-    projectId,
-    reset,
-    selectedItems,
-    setError,
-    setInFlight,
-    updateProject,
-  ])
-
-  const privilegeOptions = useMemo(() => {
-    const options: {
-      key: PermissionsLevel
-      label: string
-      description?: string | null
-    }[] = [
-      {
-        key: 'readAndWrite',
-        label: t('editor'),
-      },
-    ]
-
-    if (features.trackChangesVisible) {
-      options.push({
-        key: 'review',
-        label: t('reviewer'),
-        description: !features.trackChanges
-          ? t('comment_only_upgrade_for_track_changes')
-          : null,
-      })
-    }
-
-    options.push({
-      key: 'readOnly',
-      label: t('viewer'),
-    })
-
-    return options
-  }, [features.trackChanges, features.trackChangesVisible, t])
-
   return (
   return (
     <OLForm className="add-collabs">
     <OLForm className="add-collabs">
-      <OLFormGroup>
+      {isSharingUpdatesEnabled ? (
         <SelectCollaborators
         <SelectCollaborators
           loading={!nonMemberContacts}
           loading={!nonMemberContacts}
           options={nonMemberContacts || []}
           options={nonMemberContacts || []}
           multipleSelectionProps={multipleSelectionProps}
           multipleSelectionProps={multipleSelectionProps}
+          currentMemberEmails={currentMemberEmails}
+          readOnly={readOnly}
+          size="lg"
         />
         />
-        <OLFormText id="add-collaborator-help-text">
-          {t('add_comma_separated_emails_help')}
-        </OLFormText>
-      </OLFormGroup>
-      <OLFormGroup>
-        <div className="float-end add-collaborator-controls">
-          <Select
-            dataTestId="add-collaborator-select"
-            items={privilegeOptions}
-            itemToKey={item => item.key}
-            itemToString={item => item?.label || ''}
-            itemToSubtitle={item => item?.description || ''}
-            itemToDisabled={item => !!(readOnly && item?.key !== 'readOnly')}
-            selected={privilegeOptions.find(
-              option => option.key === privileges
-            )}
-            onSelectedItemChanged={item => {
-              if (item) {
-                setPrivileges(item.key)
-              }
-            }}
-          />
-          <ClickableElementEnhancer
-            as={OLButton}
-            onClick={handleSubmit}
-            variant="primary"
-          >
-            {t('invite')}
-          </ClickableElementEnhancer>
-        </div>
-      </OLFormGroup>
+      ) : (
+        <>
+          <OLFormGroup>
+            <SelectCollaborators
+              loading={!nonMemberContacts}
+              options={nonMemberContacts || []}
+              multipleSelectionProps={multipleSelectionProps}
+              currentMemberEmails={currentMemberEmails}
+              readOnly={readOnly}
+            />
+            <OLFormText id="add-collaborator-help-text">
+              {t('add_comma_separated_emails_help')}
+            </OLFormText>
+          </OLFormGroup>
+          <OLFormGroup>
+            <div className="float-end add-collaborator-controls add-collaborator-controls-legacy">
+              <AddCollaboratorsSelect
+                readOnly={readOnly}
+                multipleSelectionProps={multipleSelectionProps}
+                currentMemberEmails={currentMemberEmails}
+              />
+            </div>
+          </OLFormGroup>
+        </>
+      )}
     </OLForm>
     </OLForm>
   )
   )
 }
 }

+ 285 - 118
services/web/frontend/js/features/share-project-modal/components/select-collaborators.tsx

@@ -5,11 +5,17 @@ import { useCombobox, UseMultipleSelectionReturnValue } from 'downshift'
 import classnames from 'classnames'
 import classnames from 'classnames'
 
 
 import MaterialIcon from '@/shared/components/material-icon'
 import MaterialIcon from '@/shared/components/material-icon'
-import Tag from '@/shared/components/tag'
 import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
 import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
 import { Contact } from '../utils/types'
 import { Contact } from '../utils/types'
 import OLFormLabel from '@/shared/components/ol/ol-form-label'
 import OLFormLabel from '@/shared/components/ol/ol-form-label'
 import OLSpinner from '@/shared/components/ol/ol-spinner'
 import OLSpinner from '@/shared/components/ol/ol-spinner'
+import OLFormFeedback from '@/shared/components/ol/ol-form-feedback'
+import OLRow from '@/shared/components/ol/ol-row'
+import OLCol from '@/shared/components/ol/ol-col'
+import OLTag from '@/shared/components/ol/ol-tag'
+import AddCollaboratorsSelect from '@/features/share-project-modal/components/add-collaborators-select'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
+import { isValidEmail } from '@/shared/utils/email'
 
 
 export type ContactItem = {
 export type ContactItem = {
   email: string
   email: string
@@ -23,15 +29,33 @@ export type ContactItem = {
 const matchAllSpaces =
 const matchAllSpaces =
   /[\u061C\u2000-\u200F\u202A-\u202E\u2060\u2066-\u2069\u2028\u2029\u202F]/g
   /[\u061C\u2000-\u200F\u202A-\u202E\u2060\u2066-\u2069\u2028\u2029\u202F]/g
 
 
+const INPUT_ERRORS = {
+  ALREADY_MEMBER: 'ALREADY_MEMBER',
+  INVALID_FORMAT: 'INVALID_FORMAT',
+} as const
+
+type InputError = {
+  key: `${InputError['email']}:${(typeof INPUT_ERRORS)[keyof typeof INPUT_ERRORS]}`
+  email: string
+  message: string
+}
+
 export default function SelectCollaborators({
 export default function SelectCollaborators({
   loading,
   loading,
   options,
   options,
   multipleSelectionProps,
   multipleSelectionProps,
+  currentMemberEmails,
+  readOnly,
+  size,
 }: {
 }: {
   loading: boolean
   loading: boolean
   options: Contact[]
   options: Contact[]
   multipleSelectionProps: UseMultipleSelectionReturnValue<ContactItem>
   multipleSelectionProps: UseMultipleSelectionReturnValue<ContactItem>
+  currentMemberEmails: string[]
+  readOnly?: boolean
+  size?: 'lg'
 }) {
 }) {
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
   const { t } = useTranslation()
   const { t } = useTranslation()
   const {
   const {
     getSelectedItemProps,
     getSelectedItemProps,
@@ -42,6 +66,8 @@ export default function SelectCollaborators({
   } = multipleSelectionProps
   } = multipleSelectionProps
 
 
   const [inputValue, setInputValue] = useState('')
   const [inputValue, setInputValue] = useState('')
+  const [inviteSent, setInviteSent] = useState(false)
+  const [inputErrors, setInputErrors] = useState<InputError[]>([])
 
 
   const selectedEmails = useMemo(
   const selectedEmails = useMemo(
     () => selectedItems.map(item => item.email),
     () => selectedItems.map(item => item.email),
@@ -146,13 +172,60 @@ export default function SelectCollaborators({
         })
         })
         setInputValue('')
         setInputValue('')
         reset()
         reset()
+
+        const inputErrorsSetter = (
+          inputErrors: InputError[],
+          errorType: (typeof INPUT_ERRORS)[keyof typeof INPUT_ERRORS],
+          message: InputError['message']
+        ) => {
+          const key = `${email}:${errorType}` as const
+          return [
+            ...inputErrors.filter(e => !(e.email === email && e.key === key)),
+            {
+              key,
+              email,
+              message,
+            },
+          ]
+        }
+
+        // Validate email format
+        if (!isValidEmail(email)) {
+          setInputErrors(prev =>
+            inputErrorsSetter(
+              prev,
+              INPUT_ERRORS.INVALID_FORMAT,
+              t('email_must_be_a_valid_format')
+            )
+          )
+        }
+
+        // Validate against existing members
+        if (currentMemberEmails.includes(email.toLowerCase())) {
+          setInputErrors(prev =>
+            inputErrorsSetter(
+              prev,
+              INPUT_ERRORS.ALREADY_MEMBER,
+              t('only_add_people_who_dont_yet_have_access')
+            )
+          )
+        }
+
         if (focus) {
         if (focus) {
           focusInput()
           focusInput()
         }
         }
         return true
         return true
       }
       }
     },
     },
-    [addSelectedItem, selectedEmails, isValidInput, focusInput, reset]
+    [
+      addSelectedItem,
+      selectedEmails,
+      isValidInput,
+      focusInput,
+      reset,
+      currentMemberEmails,
+      t,
+    ]
   )
   )
 
 
   // close and reset the menu when there are no matching items
   // close and reset the menu when there are no matching items
@@ -162,123 +235,197 @@ export default function SelectCollaborators({
     }
     }
   }, [reset, isOpen, filteredOptions.length])
   }, [reset, isOpen, filteredOptions.length])
 
 
+  useEffect(() => {
+    setInputErrors(prev => prev.filter(e => selectedEmails.includes(e.email)))
+  }, [selectedEmails])
+
   return (
   return (
     <div className="tags-input tags-new">
     <div className="tags-input tags-new">
-      {/* eslint-disable-next-line jsx-a11y/label-has-for */}
-      <OLFormLabel className="small" {...getLabelProps()}>
-        {t('add_email_address')}
-        {loading && <OLSpinner size="sm" className="ms-2" />}
-      </OLFormLabel>
-
-      <div className="host">
-        {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
-        <div className="tags form-control" onClick={focusInput}>
-          {selectedItems.map((selectedItem, index) => (
-            <SelectedItem
-              key={`selected-item-${index}`}
-              removeSelectedItem={removeSelectedItem}
-              selectedItem={selectedItem}
-              focusInput={focusInput}
-              index={index}
-              getSelectedItemProps={getSelectedItemProps}
-            />
-          ))}
-
-          <input
-            data-testid="collaborator-email-input"
-            aria-describedby="add-collaborator-help-text"
-            {...getInputProps(
-              getDropdownProps({
-                className: classnames('input', {
-                  'invalid-tag': !isValidInput,
-                }),
-                type: 'email',
-                size: inputValue.length ? inputValue.length + 5 : 5,
-                ref: inputRef,
-                // preventKeyAction: showDropdown,
-                onBlur: () => {
-                  addNewItem(inputValue, false)
-                },
-                onChange: e => {
-                  setInputValue((e.target as HTMLInputElement).value)
-                },
-                onClick: () => focusInput,
-                onKeyDown: event => {
-                  switch (event.key) {
-                    case 'Enter':
-                      // Enter: always prevent form submission
-                      event.preventDefault()
-                      event.stopPropagation()
-                      break
-
-                    case 'Tab':
-                      // Tab: if the dropdown isn't open, try to create a new item using inputValue and prevent blur if successful
-                      if (!isOpen && addNewItem(inputValue)) {
-                        event.preventDefault()
-                        event.stopPropagation()
-                      }
-                      break
-
-                    case ',':
-                      // comma: try to create a new item using inputValue
-                      event.preventDefault()
-                      addNewItem(inputValue)
-                      break
-                  }
-                },
-                onPaste: event => {
-                  const data =
-                    // modern browsers
-                    event.clipboardData?.getData('text/plain') ??
-                    // @ts-ignore IE11
-                    window.clipboardData?.getData('text')
-
-                  if (data) {
-                    const emails = data
-                      .split(/[\r\n,; ]+/)
-                      .filter(item => item.includes('@'))
-                      .map(email => email.replace(matchAllSpaces, ''))
-
-                    if (emails.length) {
-                      // pasted comma-separated email addresses
-                      event.preventDefault()
-
-                      // dedupe emails in pasted content and previously-entered items
-                      const uniqueEmails = [...new Set(emails)].filter(
-                        email => !selectedEmails.includes(email)
-                      )
-
-                      for (const email of uniqueEmails) {
-                        addNewItem(email)
-                      }
-                    }
-                  }
-                },
-              })
-            )}
-          />
-        </div>
-
-        <div>
-          <ul
-            {...getMenuProps()}
-            className={classnames('dropdown-menu select-dropdown-menu', {
-              show: isOpen,
-            })}
-          >
-            {isOpen &&
-              filteredOptions.map((item, index) => (
-                <Option
-                  key={item.email}
+      {isSharingUpdatesEnabled ? (
+        // eslint-disable-next-line jsx-a11y/label-has-for
+        <OLFormLabel {...getLabelProps()}>
+          {t('enter_emails_separated_by_commas')}
+        </OLFormLabel>
+      ) : (
+        // eslint-disable-next-line jsx-a11y/label-has-for
+        <OLFormLabel className="small" {...getLabelProps()}>
+          {t('add_email_address')}
+          {loading && <OLSpinner size="sm" className="ms-2" />}
+        </OLFormLabel>
+      )}
+
+      <OLRow
+        className={classnames('align-items-start', {
+          'g-2': isSharingUpdatesEnabled,
+        })}
+      >
+        <OLCol>
+          <div className="host">
+            {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
+            <div
+              className={classnames('tags form-control', {
+                'form-control-lg': size === 'lg',
+                'is-invalid':
+                  !isValidInput ||
+                  (isSharingUpdatesEnabled && inputErrors.length > 0),
+              })}
+              onClick={focusInput}
+            >
+              {selectedItems.map((selectedItem, index) => (
+                <SelectedItem
+                  key={`selected-item-${index}`}
+                  removeSelectedItem={removeSelectedItem}
+                  selectedItem={selectedItem}
+                  focusInput={focusInput}
                   index={index}
                   index={index}
-                  item={item}
-                  selected={index === highlightedIndex}
-                  getItemProps={getItemProps}
+                  getSelectedItemProps={getSelectedItemProps}
+                  hasIssue={inputErrors.some(
+                    ({ email }) => email === selectedItem.email
+                  )}
                 />
                 />
               ))}
               ))}
-          </ul>
-        </div>
-      </div>
+
+              <input
+                data-testid="collaborator-email-input"
+                aria-describedby={
+                  isSharingUpdatesEnabled
+                    ? undefined
+                    : 'add-collaborator-help-text'
+                }
+                {...getInputProps(
+                  getDropdownProps({
+                    className: classnames('input', {
+                      'invalid-tag': !isSharingUpdatesEnabled && !isValidInput,
+                      'is-invalid': !isSharingUpdatesEnabled && !isValidInput,
+                    }),
+                    type: 'email',
+                    size: inputValue.length ? inputValue.length + 5 : 5,
+                    ref: inputRef,
+                    // preventKeyAction: showDropdown,
+                    onBlur: () => {
+                      addNewItem(inputValue, false)
+                    },
+                    onChange: e => {
+                      setInputValue((e.target as HTMLInputElement).value)
+                      setInviteSent(false)
+                    },
+                    onClick: () => focusInput,
+                    onKeyDown: event => {
+                      switch (event.key) {
+                        case 'Enter':
+                          // Enter: always prevent form submission
+                          event.preventDefault()
+                          event.stopPropagation()
+                          break
+
+                        case 'Tab':
+                          // Tab: if the dropdown isn't open, try to create a new item using inputValue and prevent blur if successful
+                          if (!isOpen && addNewItem(inputValue)) {
+                            event.preventDefault()
+                            event.stopPropagation()
+                          }
+                          break
+
+                        case ',':
+                          // comma: try to create a new item using inputValue
+                          event.preventDefault()
+                          addNewItem(inputValue)
+                          break
+                      }
+                    },
+                    onPaste: event => {
+                      const data =
+                        // modern browsers
+                        event.clipboardData?.getData('text/plain') ??
+                        // @ts-ignore IE11
+                        window.clipboardData?.getData('text')
+
+                      if (data) {
+                        const emails = data
+                          .split(/[\r\n,; ]+/)
+                          .filter(item => item.includes('@'))
+                          .map(email => email.replace(matchAllSpaces, ''))
+
+                        if (emails.length) {
+                          // pasted comma-separated email addresses
+                          event.preventDefault()
+
+                          // dedupe emails in pasted content and previously-entered items
+                          const uniqueEmails = [...new Set(emails)].filter(
+                            email => !selectedEmails.includes(email)
+                          )
+
+                          for (const email of uniqueEmails) {
+                            addNewItem(email)
+                          }
+                        }
+                      }
+                    },
+                  })
+                )}
+              />
+            </div>
+
+            <div>
+              <ul
+                {...getMenuProps()}
+                className={classnames('dropdown-menu select-dropdown-menu', {
+                  show: isOpen,
+                })}
+              >
+                {isOpen &&
+                  filteredOptions.map((item, index) => (
+                    <Option
+                      key={item.email}
+                      index={index}
+                      item={item}
+                      selected={index === highlightedIndex}
+                      getItemProps={getItemProps}
+                    />
+                  ))}
+              </ul>
+            </div>
+          </div>
+        </OLCol>
+        {isSharingUpdatesEnabled && (
+          <OLCol xs="auto">
+            <div className="add-collaborator-controls">
+              <AddCollaboratorsSelect
+                readOnly={readOnly}
+                multipleSelectionProps={multipleSelectionProps}
+                currentMemberEmails={currentMemberEmails}
+                inputValue={inputValue}
+                onInviteSuccess={() => setInviteSent(true)}
+                hasErrors={inputErrors.length > 0}
+              />
+            </div>
+          </OLCol>
+        )}
+      </OLRow>
+      {isSharingUpdatesEnabled && (
+        <>
+          {inputErrors.length > 0 ? (
+            inputErrors.length === 1 ? (
+              <OLFormFeedback type="invalid" unfilled className="d-block">
+                {inputErrors[0].message}
+              </OLFormFeedback>
+            ) : (
+              <ul className="text-danger m-0 mt-2">
+                {inputErrors.map(inputError => (
+                  <li key={inputError.key} className="small p-0">
+                    {inputError.message}
+                  </li>
+                ))}
+              </ul>
+            )
+          ) : null}
+          {inputErrors.length === 0 && inviteSent && (
+            <OLFormFeedback type="valid" unfilled className="d-block">
+              {t('invitations_sent')}
+            </OLFormFeedback>
+          )}
+        </>
+      )}
     </div>
     </div>
   )
   )
 }
 }
@@ -294,12 +441,19 @@ function Option({
   getItemProps: (any: any) => any
   getItemProps: (any: any) => any
   index: number
   index: number
 }) {
 }) {
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
   return (
   return (
     <li {...getItemProps({ item, index })}>
     <li {...getItemProps({ item, index })}>
       <DropdownItem
       <DropdownItem
         as="span"
         as="span"
         role={undefined}
         role={undefined}
-        leadingIcon="person"
+        leadingIcon={
+          isSharingUpdatesEnabled ? (
+            <MaterialIcon type="person" unfilled />
+          ) : (
+            <MaterialIcon type="person" />
+          )
+        }
         className={classnames({
         className={classnames({
           active: selected,
           active: selected,
         })}
         })}
@@ -316,13 +470,16 @@ function SelectedItem({
   focusInput,
   focusInput,
   getSelectedItemProps,
   getSelectedItemProps,
   index,
   index,
+  hasIssue,
 }: {
 }: {
   removeSelectedItem: (item: ContactItem) => void
   removeSelectedItem: (item: ContactItem) => void
   selectedItem: ContactItem
   selectedItem: ContactItem
   focusInput: () => void
   focusInput: () => void
   getSelectedItemProps: (any: any) => any
   getSelectedItemProps: (any: any) => any
   index: number
   index: number
+  hasIssue?: boolean
 }) {
 }) {
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
   const handleClick = useCallback(
   const handleClick = useCallback(
     (event: React.MouseEvent) => {
     (event: React.MouseEvent) => {
       event.preventDefault()
       event.preventDefault()
@@ -333,15 +490,25 @@ function SelectedItem({
     [focusInput, removeSelectedItem, selectedItem]
     [focusInput, removeSelectedItem, selectedItem]
   )
   )
 
 
+  let prepend: React.ReactNode
+  if (isSharingUpdatesEnabled) {
+    if (hasIssue) {
+      prepend = <MaterialIcon type="error" unfilled className="text-danger" />
+    }
+  } else {
+    prepend = <MaterialIcon type="person" />
+  }
+
   return (
   return (
-    <Tag
-      prepend={<MaterialIcon type="person" />}
+    <OLTag
+      prepend={prepend}
       closeBtnProps={{
       closeBtnProps={{
         onClick: handleClick,
         onClick: handleClick,
       }}
       }}
+      translate="no"
       {...getSelectedItemProps({ selectedItem, index })}
       {...getSelectedItemProps({ selectedItem, index })}
     >
     >
       {selectedItem.display}
       {selectedItem.display}
-    </Tag>
+    </OLTag>
   )
   )
 }
 }

+ 23 - 26
services/web/frontend/js/features/share-project-modal/components/send-invites-notice.tsx

@@ -1,21 +1,29 @@
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import { useProjectContext } from '@/shared/context/project-context'
 import { useProjectContext } from '@/shared/context/project-context'
-import Notification from '@/shared/components/notification'
-import { PublicAccessLevel } from '../../../../../types/public-access-level'
 import { useEditorContext } from '@/shared/context/editor-context'
 import { useEditorContext } from '@/shared/context/editor-context'
 import OLRow from '@/shared/components/ol/ol-row'
 import OLRow from '@/shared/components/ol/ol-row'
 import OLCol from '@/shared/components/ol/ol-col'
 import OLCol from '@/shared/components/ol/ol-col'
+import OLNotification from '@/shared/components/ol/ol-notification'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 
-export default function SendInvitesNotice() {
+function SendInvitesNotice() {
+  const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
   const { project } = useProjectContext()
   const { project } = useProjectContext()
   const { publicAccessLevel } = project || {}
   const { publicAccessLevel } = project || {}
   const { isPendingEditor } = useEditorContext()
   const { isPendingEditor } = useEditorContext()
   const { t } = useTranslation()
   const { t } = useTranslation()
 
 
+  let accessLevelText = ''
+  if (publicAccessLevel === 'private') {
+    accessLevelText = t('to_add_more_collaborators')
+  } else if (publicAccessLevel === 'tokenBased') {
+    accessLevelText = t('to_change_access_permissions')
+  }
+
   return (
   return (
     <div>
     <div>
       {isPendingEditor && (
       {isPendingEditor && (
-        <Notification
+        <OLNotification
           isActionBelowContent
           isActionBelowContent
           type="info"
           type="info"
           title={t('youve_lost_collaboration_access')}
           title={t('youve_lost_collaboration_access')}
@@ -31,29 +39,18 @@ export default function SendInvitesNotice() {
           }
           }
         />
         />
       )}
       )}
-      <OLRow className="public-access-level public-access-level-notice">
-        <OLCol className="text-center">
-          <AccessLevel level={publicAccessLevel} />
-        </OLCol>
-      </OLRow>
+      {accessLevelText &&
+        (isSharingUpdatesEnabled ? (
+          <OLNotification type="info" content={accessLevelText} />
+        ) : (
+          <OLRow className="public-access-level public-access-level-notice">
+            <OLCol className="text-center">
+              <span>{accessLevelText}</span>
+            </OLCol>
+          </OLRow>
+        ))}
     </div>
     </div>
   )
   )
 }
 }
 
 
-type AccessLevelProps = {
-  level: PublicAccessLevel | undefined
-}
-
-function AccessLevel({ level }: AccessLevelProps) {
-  const { t } = useTranslation()
-  switch (level) {
-    case 'private':
-      return <span>{t('to_add_more_collaborators')}</span>
-
-    case 'tokenBased':
-      return <span>{t('to_change_access_permissions')}</span>
-
-    default:
-      return <span>''</span>
-  }
-}
+export default SendInvitesNotice

+ 0 - 4
services/web/frontend/js/features/share-project-modal/components/send-invites.tsx

@@ -19,10 +19,6 @@ export default function SendInvites({
 }) {
 }) {
   const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
   const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
 
 
-  if (isSharingUpdatesEnabled) {
-    return null
-  }
-
   return (
   return (
     <OLRow
     <OLRow
       className={classnames('invite-controls', {
       className={classnames('invite-controls', {

+ 6 - 3
services/web/frontend/js/shared/components/form/form-feedback.tsx

@@ -5,12 +5,15 @@ import { ComponentProps } from 'react'
 export type FormFeedbackProps = Pick<
 export type FormFeedbackProps = Pick<
   ComponentProps<typeof Form.Control.Feedback>,
   ComponentProps<typeof Form.Control.Feedback>,
   'type' | 'className' | 'children'
   'type' | 'className' | 'children'
->
+> & { unfilled?: boolean }
 
 
-function FormFeedback(props: FormFeedbackProps) {
+function FormFeedback({ unfilled, ...props }: FormFeedbackProps) {
   return (
   return (
     <Form.Control.Feedback {...props}>
     <Form.Control.Feedback {...props}>
-      <FormText type={props.type === 'invalid' ? 'error' : 'success'}>
+      <FormText
+        type={props.type === 'invalid' ? 'error' : 'success'}
+        unfilled={unfilled}
+      >
         {props.children}
         {props.children}
       </FormText>
       </FormText>
     </Form.Control.Feedback>
     </Form.Control.Feedback>

+ 27 - 6
services/web/frontend/js/shared/components/form/form-text.tsx

@@ -9,6 +9,7 @@ export type FormTextProps = MergeAndOverride<
   BS5FormTextProps,
   BS5FormTextProps,
   {
   {
     type?: TextType
     type?: TextType
+    unfilled?: boolean
     marginless?: boolean
     marginless?: boolean
   }
   }
 >
 >
@@ -22,16 +23,35 @@ const typeClassMap: Partial<Record<TextType, string>> = {
 export const getFormTextClass = (type?: TextType) =>
 export const getFormTextClass = (type?: TextType) =>
   typeClassMap[type || 'default']
   typeClassMap[type || 'default']
 
 
-function FormTextIcon({ type }: { type?: TextType }) {
+function FormTextIcon({
+  type,
+  unfilled,
+}: Pick<FormTextProps, 'type' | 'unfilled'>) {
   switch (type) {
   switch (type) {
     case 'info':
     case 'info':
-      return <MaterialIcon type="info" className="text-info" />
+      return unfilled ? (
+        <MaterialIcon type="info" className="text-info" unfilled />
+      ) : (
+        <MaterialIcon type="info" className="text-info" />
+      )
     case 'success':
     case 'success':
-      return <MaterialIcon type="check_circle" />
+      return unfilled ? (
+        <MaterialIcon type="check_circle" unfilled />
+      ) : (
+        <MaterialIcon type="check_circle" />
+      )
     case 'warning':
     case 'warning':
-      return <MaterialIcon type="warning" />
+      return unfilled ? (
+        <MaterialIcon type="warning" unfilled />
+      ) : (
+        <MaterialIcon type="warning" />
+      )
     case 'error':
     case 'error':
-      return <MaterialIcon type="error" />
+      return unfilled ? (
+        <MaterialIcon type="error" unfilled />
+      ) : (
+        <MaterialIcon type="error" />
+      )
     default:
     default:
       return null
       return null
   }
   }
@@ -42,6 +62,7 @@ function FormText({
   marginless,
   marginless,
   children,
   children,
   className,
   className,
+  unfilled,
   ...rest
   ...rest
 }: FormTextProps) {
 }: FormTextProps) {
   return (
   return (
@@ -50,7 +71,7 @@ function FormText({
       {...rest}
       {...rest}
     >
     >
       <span className="form-text-inner">
       <span className="form-text-inner">
-        <FormTextIcon type={type} />
+        <FormTextIcon type={type} unfilled={unfilled} />
         <span>{children}</span>
         <span>{children}</span>
       </span>
       </span>
     </Form.Text>
     </Form.Text>

+ 4 - 9
services/web/frontend/js/shared/components/ol/ol-form-feedback.tsx

@@ -1,13 +1,8 @@
-import { Form } from 'react-bootstrap'
-import { ComponentProps } from 'react'
-import FormFeedback from '@/shared/components/form/form-feedback'
+import FormFeedback, {
+  FormFeedbackProps,
+} from '@/shared/components/form/form-feedback'
 
 
-type OLFormFeedbackProps = Pick<
-  ComponentProps<typeof Form.Control.Feedback>,
-  'type' | 'className' | 'children'
->
-
-function OLFormFeedback(props: OLFormFeedbackProps) {
+function OLFormFeedback(props: FormFeedbackProps) {
   return <FormFeedback {...props} />
   return <FormFeedback {...props} />
 }
 }
 
 

+ 12 - 0
services/web/frontend/js/shared/components/select.tsx

@@ -18,6 +18,7 @@ import OLSpinner from './ol/ol-spinner'
 import DSFormLabel from '@/shared/components/ds/ds-form-label'
 import DSFormLabel from '@/shared/components/ds/ds-form-label'
 import DSFormGroup from '@/shared/components/ds/ds-form-group'
 import DSFormGroup from '@/shared/components/ds/ds-form-group'
 import DSFormControl from '@/shared/components/ds/ds-form-control'
 import DSFormControl from '@/shared/components/ds/ds-form-control'
+import { DropdownItemProps } from '@/shared/components/types/dropdown-menu-props'
 
 
 export type SelectProps<T> = {
 export type SelectProps<T> = {
   // The items rendered as dropdown options.
   // The items rendered as dropdown options.
@@ -39,6 +40,10 @@ export type SelectProps<T> = {
   itemToSubtitle?: (item: T | null | undefined) => string
   itemToSubtitle?: (item: T | null | undefined) => string
   // Stringifies an item. The resulting string is rendered as a React `key` for each item.
   // Stringifies an item. The resulting string is rendered as a React `key` for each item.
   itemToKey: (item: T) => string
   itemToKey: (item: T) => string
+  // Maps an item to a leading icon.
+  itemToLeadingIcon?: (
+    item: T | null | undefined
+  ) => DropdownItemProps['leadingIcon']
   // Callback invoked after the selected item is updated.
   // Callback invoked after the selected item is updated.
   onSelectedItemChanged?: (item: T | null | undefined) => void
   onSelectedItemChanged?: (item: T | null | undefined) => void
   // Optionally directly control the selected item.
   // Optionally directly control the selected item.
@@ -57,6 +62,7 @@ export type SelectProps<T> = {
   dataTestId?: string
   dataTestId?: string
   // CIAM-specific layout
   // CIAM-specific layout
   isCiam?: boolean
   isCiam?: boolean
+  size?: React.ComponentProps<typeof FormControl>['size']
 }
 }
 
 
 export const Select = <T,>({
 export const Select = <T,>({
@@ -68,6 +74,7 @@ export const Select = <T,>({
   defaultItem,
   defaultItem,
   itemToSubtitle,
   itemToSubtitle,
   itemToKey,
   itemToKey,
+  itemToLeadingIcon,
   onSelectedItemChanged,
   onSelectedItemChanged,
   selected,
   selected,
   disabled = false,
   disabled = false,
@@ -77,6 +84,7 @@ export const Select = <T,>({
   selectedIcon = false,
   selectedIcon = false,
   dataTestId,
   dataTestId,
   isCiam,
   isCiam,
+  size,
 }: SelectProps<T>) => {
 }: SelectProps<T>) => {
   const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
   const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
     defaultItem
     defaultItem
@@ -185,6 +193,9 @@ export const Select = <T,>({
                 trailingIcon={
                 trailingIcon={
                   selectedIcon && selectedItem === item ? tickIcon() : undefined
                   selectedIcon && selectedItem === item ? tickIcon() : undefined
                 }
                 }
+                leadingIcon={
+                  itemToLeadingIcon ? itemToLeadingIcon(item) : undefined
+                }
                 description={itemToSubtitle ? itemToSubtitle(item) : undefined}
                 description={itemToSubtitle ? itemToSubtitle(item) : undefined}
                 {...itemProps}
                 {...itemProps}
                 disabled={disabled}
                 disabled={disabled}
@@ -250,6 +261,7 @@ export const Select = <T,>({
             className="align-text-bottom"
             className="align-text-bottom"
           />
           />
         }
         }
+        size={size}
       />
       />
       {dropdown}
       {dropdown}
     </div>
     </div>

+ 15 - 0
services/web/frontend/stylesheets/pages/editor/tags-input.scss

@@ -37,6 +37,21 @@
       }
       }
     }
     }
   }
   }
+
+  &.form-control-lg {
+    min-height: initial;
+    padding-top: calc(#{$input-padding-y-lg} - #{$input-border-width} * 2);
+    padding-bottom: calc(#{$input-padding-y-lg} - #{$input-border-width} * 2);
+  }
+}
+
+.modal-redesign {
+  .tags-input .tags {
+    .badge-tag {
+      margin-top: 1px;
+      margin-bottom: 1px;
+    }
+  }
 }
 }
 
 
 .tags-input .tags:focus-within {
 .tags-input .tags:focus-within {

+ 4 - 0
services/web/locales/en.json

@@ -755,6 +755,7 @@
   "email_does_not_belong_to_university": "We don’t recognize that domain as being affiliated with your university. Please contact us to add the affiliation.",
   "email_does_not_belong_to_university": "We don’t recognize that domain as being affiliated with your university. Please contact us to add the affiliation.",
   "email_limit_reached": "You can have a maximum of <0>__emailAddressLimit__ email addresses</0> on this account. To add another email address, please delete an existing one.",
   "email_limit_reached": "You can have a maximum of <0>__emailAddressLimit__ email addresses</0> on this account. To add another email address, please delete an existing one.",
   "email_link_expired": "Email link expired, please request a new one.",
   "email_link_expired": "Email link expired, please request a new one.",
+  "email_must_be_a_valid_format": "Email addresses must be a valid format.",
   "email_must_be_linked_to_institution": "As a member of __institutionName__, this email address can only be added via single sign-on on your <0>account settings</0> page. Please add a different recovery email address.",
   "email_must_be_linked_to_institution": "As a member of __institutionName__, this email address can only be added via single sign-on on your <0>account settings</0> page. Please add a different recovery email address.",
   "email_notifications_are_currently_in_beta": "Email notifications are currently in beta.",
   "email_notifications_are_currently_in_beta": "Email notifications are currently in beta.",
   "email_or_password_wrong_try_again": "Your email or password is incorrect. Please try again.",
   "email_or_password_wrong_try_again": "Your email or password is incorrect. Please try again.",
@@ -781,6 +782,7 @@
   "end_time_utc": "End time (UTC)",
   "end_time_utc": "End time (UTC)",
   "ensure_recover_account": "This will ensure that it can be used to recover your __appName__ account in case you lose access to your primary email address.",
   "ensure_recover_account": "This will ensure that it can be used to recover your __appName__ account in case you lose access to your primary email address.",
   "enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command",
   "enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command",
+  "enter_emails_separated_by_commas": "Enter emails separated by commas",
   "enter_manually": "Enter manually",
   "enter_manually": "Enter manually",
   "enter_tax_id_number": "Enter tax ID number",
   "enter_tax_id_number": "Enter tax ID number",
   "enter_the_code": "Enter the 6-digit code sent to __email__.",
   "enter_the_code": "Enter the 6-digit code sent to __email__.",
@@ -1257,6 +1259,7 @@
   "invalid_zip_file": "Invalid zip file",
   "invalid_zip_file": "Invalid zip file",
   "invert_pdf_preview_colors": "Invert PDF preview colors",
   "invert_pdf_preview_colors": "Invert PDF preview colors",
   "invert_pdf_preview_colors_when_in_dark_mode": "Invert PDF preview colors when in dark mode",
   "invert_pdf_preview_colors_when_in_dark_mode": "Invert PDF preview colors when in dark mode",
+  "invitations_sent": "Invitation(s) sent.",
   "invite": "Invite",
   "invite": "Invite",
   "invite_expired": "The invite may have expired",
   "invite_expired": "The invite may have expired",
   "invite_group_members": "Invite group members",
   "invite_group_members": "Invite group members",
@@ -1694,6 +1697,7 @@
   "one_user": "1 user",
   "one_user": "1 user",
   "ongoing_experiments": "Ongoing experiments",
   "ongoing_experiments": "Ongoing experiments",
   "online_latex_editor": "Online LaTeX Editor",
   "online_latex_editor": "Online LaTeX Editor",
+  "only_add_people_who_dont_yet_have_access": "Only add people who don’t yet have access.",
   "only_group_admin_or_managers_can_delete_your_account_1": "By becoming a managed user, your organization will have admin rights over your account and control over your stuff, including the right to close your account and access, delete and share your stuff. As a result:",
   "only_group_admin_or_managers_can_delete_your_account_1": "By becoming a managed user, your organization will have admin rights over your account and control over your stuff, including the right to close your account and access, delete and share your stuff. As a result:",
   "only_group_admin_or_managers_can_delete_your_account_10": "If you have an individual subscription, we’ll automatically terminate it and cancel its renewal when your account becomes managed. To request a pro-rata refund for the remainder, please contact Support.",
   "only_group_admin_or_managers_can_delete_your_account_10": "If you have an individual subscription, we’ll automatically terminate it and cancel its renewal when your account becomes managed. To request a pro-rata refund for the remainder, please contact Support.",
   "only_group_admin_or_managers_can_delete_your_account_3": "Your group admin and group managers will be able to reassign ownership of your projects to another group member.",
   "only_group_admin_or_managers_can_delete_your_account_3": "Your group admin and group managers will be able to reassign ownership of your projects to another group member.",

+ 123 - 0
services/web/test/frontend/features/share-project-modal/components/share-project-modal.test.tsx

@@ -1052,4 +1052,127 @@ describe('<ShareProjectModal/>', function () {
       )
       )
     })
     })
   })
   })
+
+  describe('with "sharing-updates" feature flag', function () {
+    beforeEach(function () {
+      window.metaAttributesCache.set('ol-splitTestVariants', {
+        'sharing-updates': 'enabled',
+      })
+    })
+
+    afterEach(function () {
+      window.metaAttributesCache.set('ol-splitTestVariants', {})
+    })
+
+    it('disables the invite button when no email is entered', async function () {
+      renderWithEditorContext(
+        <ShareProjectModal {...modalProps} />,
+        createContextProps({ publicAccessLevel: 'tokenBased' })
+      )
+
+      const inviteButton = (await screen.findByRole('button', {
+        name: /invite/i,
+      })) as HTMLButtonElement
+
+      expect(inviteButton.disabled).to.be.true
+    })
+
+    it('enables the invite button once a valid email is typed', async function () {
+      renderWithEditorContext(
+        <ShareProjectModal {...modalProps} />,
+        createContextProps({ publicAccessLevel: 'tokenBased' })
+      )
+
+      const inviteButton = (await screen.findByRole('button', {
+        name: /invite/i,
+      })) as HTMLButtonElement
+
+      expect(inviteButton.disabled).to.be.true
+
+      const inputElement = await screen.findByTestId('collaborator-email-input')
+      fireEvent.change(inputElement, {
+        target: { value: 'new@example.com' },
+      })
+
+      await waitFor(() => expect(inviteButton.disabled).to.be.false)
+    })
+
+    it('shows a validation error and disables invite button for an invalid email format', async function () {
+      renderWithEditorContext(
+        <ShareProjectModal {...modalProps} />,
+        createContextProps({ publicAccessLevel: 'tokenBased' })
+      )
+
+      const inputElement = await screen.findByTestId('collaborator-email-input')
+      fireEvent.change(inputElement, { target: { value: 'invalid@' } })
+      fireEvent.blur(inputElement)
+
+      await screen.findByText('Email addresses must be a valid format.')
+
+      const inviteButton = screen.getByRole('button', {
+        name: /invite/i,
+      }) as HTMLButtonElement
+      expect(inviteButton.disabled).to.be.true
+    })
+
+    it('shows a validation error and disables invite button when email already has access', async function () {
+      const members: ProjectMember[] = [
+        {
+          _id: 'existing-member' as UserId,
+          email: 'member@example.com',
+          privileges: 'readAndWrite',
+          first_name: 'Existing',
+          last_name: 'Member',
+        },
+      ]
+
+      renderWithEditorContext(
+        <ShareProjectModal {...modalProps} />,
+        createContextProps({ publicAccessLevel: 'tokenBased', members })
+      )
+
+      const inputElement = await screen.findByTestId('collaborator-email-input')
+      fireEvent.change(inputElement, {
+        target: { value: 'member@example.com' },
+      })
+      fireEvent.blur(inputElement)
+
+      await screen.findByText('Only add people who don’t yet have access.')
+
+      const inviteButton = screen.getByRole('button', {
+        name: /invite/i,
+      }) as HTMLButtonElement
+      expect(inviteButton.disabled).to.be.true
+    })
+
+    it('shows "invitations sent" message after a successful invite', async function () {
+      fetchMock.post('express:/project/:projectId/invite', {
+        status: 200,
+        body: {
+          invite: {
+            _id: 'new-invite',
+            email: 'new@example.com',
+            privileges: 'readAndWrite',
+          },
+        },
+      })
+
+      renderWithEditorContext(
+        <ShareProjectModal {...modalProps} />,
+        createContextProps({ publicAccessLevel: 'tokenBased' })
+      )
+
+      const inputElement = await screen.findByTestId('collaborator-email-input')
+      fireEvent.change(inputElement, { target: { value: 'new@example.com' } })
+      fireEvent.blur(inputElement)
+
+      const inviteButton = (await screen.findByRole('button', {
+        name: /invite/i,
+      })) as HTMLButtonElement
+      await waitFor(() => expect(inviteButton.disabled).to.be.false)
+      await userEvent.click(inviteButton)
+
+      await screen.findByText('Invitation(s) sent.')
+    })
+  })
 })
 })