Sfoglia il codice sorgente

Merge pull request #17921 from overleaf/ii-bs5-alert-continue

[web] Replace all alerts with notifications in settings page

GitOrigin-RevId: a05755c39d2e54b3f57ffa589885e3e96aee00dc
ilkin-overleaf 2 anni fa
parent
commit
1e7053cb8e
16 ha cambiato i file con 473 aggiunte e 355 eliminazioni
  1. 10 3
      services/web/frontend/js/features/settings/components/account-info-section.tsx
  2. 9 4
      services/web/frontend/js/features/settings/components/emails/add-email/layout.tsx
  3. 231 41
      services/web/frontend/js/features/settings/components/emails/reconfirmation-info.tsx
  4. 51 0
      services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt-text.tsx
  5. 0 186
      services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt.tsx
  6. 10 8
      services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-success.tsx
  7. 72 47
      services/web/frontend/js/features/settings/components/emails/sso-alert.tsx
  8. 13 8
      services/web/frontend/js/features/settings/components/leave/modal-form-error.tsx
  9. 21 15
      services/web/frontend/js/features/settings/components/leavers-survey-alert.tsx
  10. 9 5
      services/web/frontend/js/features/settings/components/linking-section.tsx
  11. 36 32
      services/web/frontend/js/features/settings/components/password-section.tsx
  12. 1 1
      services/web/frontend/js/features/ui/components/bootstrap-5/notification-wrapper.tsx
  13. 1 1
      services/web/frontend/js/shared/components/notification.tsx
  14. 2 0
      services/web/frontend/stylesheets/app/account-settings.less
  15. 4 4
      services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss
  16. 3 0
      services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx

+ 10 - 3
services/web/frontend/js/features/settings/components/account-info-section.tsx

@@ -1,5 +1,5 @@
 import { useState } from 'react'
-import { Alert, ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
+import { ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import {
   getUserFacingMessage,
@@ -10,6 +10,7 @@ import { ExposedSettings } from '../../../../../types/exposed-settings'
 import useAsync from '../../../shared/hooks/use-async'
 import { useUserContext } from '../../../shared/context/user-context'
 import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
+import NotificationWrapper from '@/features/ui/components/bootstrap-5/notification-wrapper'
 
 function AccountInfoSection() {
   const { t } = useTranslation()
@@ -104,12 +105,18 @@ function AccountInfoSection() {
         />
         {isSuccess ? (
           <FormGroup>
-            <Alert bsStyle="success">{t('thanks_settings_updated')}</Alert>
+            <NotificationWrapper
+              type="success"
+              content={t('thanks_settings_updated')}
+            />
           </FormGroup>
         ) : null}
         {isError ? (
           <FormGroup>
-            <Alert bsStyle="danger">{getUserFacingMessage(error)}</Alert>
+            <NotificationWrapper
+              type="error"
+              content={getUserFacingMessage(error) ?? ''}
+            />
           </FormGroup>
         ) : null}
         {canUpdateEmail || canUpdateNames ? (

+ 9 - 4
services/web/frontend/js/features/settings/components/emails/add-email/layout.tsx

@@ -1,8 +1,8 @@
-import { Alert } from 'react-bootstrap'
 import Icon from '../../../../../shared/components/icon'
 import { UseAsyncReturnType } from '../../../../../shared/hooks/use-async'
 import { getUserFacingMessage } from '../../../../../infrastructure/fetch-json'
 import RowWrapper from '@/features/ui/components/bootstrap-5/wrappers/row-wrapper'
+import NotificationWrapper from '@/features/ui/components/bootstrap-5/notification-wrapper'
 
 type LayoutProps = {
   children: React.ReactNode
@@ -15,9 +15,14 @@ function Layout({ isError, error, children }: LayoutProps) {
     <div className="affiliations-table-row--highlighted">
       <RowWrapper>{children}</RowWrapper>
       {isError && (
-        <Alert bsStyle="danger" className="text-center">
-          <Icon type="exclamation-triangle" fw /> {getUserFacingMessage(error)}
-        </Alert>
+        <NotificationWrapper
+          type="error"
+          content={getUserFacingMessage(error) ?? ''}
+          bs3Props={{
+            icon: <Icon type="exclamation-triangle" fw />,
+            className: 'text-center',
+          }}
+        />
       )}
     </div>
   )

+ 231 - 41
services/web/frontend/js/features/settings/components/emails/reconfirmation-info.tsx

@@ -1,11 +1,23 @@
-import { ReactNode } from 'react'
+import { useState, useEffect, useLayoutEffect } from 'react'
 import { UserEmailData } from '../../../../../../types/user-email'
-import classNames from 'classnames'
 import getMeta from '../../../../utils/meta'
 import ReconfirmationInfoSuccess from './reconfirmation-info/reconfirmation-info-success'
-import ReconfirmationInfoPrompt from './reconfirmation-info/reconfirmation-info-prompt'
+import ReconfirmationInfoPromptText from './reconfirmation-info/reconfirmation-info-prompt-text'
 import RowWrapper from '@/features/ui/components/bootstrap-5/wrappers/row-wrapper'
 import ColWrapper from '@/features/ui/components/bootstrap-5/wrappers/col-wrapper'
+import NotificationWrapper from '@/features/ui/components/bootstrap-5/notification-wrapper'
+import { isBootstrap5 } from '@/features/utils/bootstrap-5'
+import Icon from '@/shared/components/icon'
+import { useUserEmailsContext } from '@/features/settings/context/user-email-context'
+import { FetchError, postJSON } from '@/infrastructure/fetch-json'
+import { debugConsole } from '@/utils/debugging'
+import { ssoAvailableForInstitution } from '@/features/settings/utils/sso'
+import { Trans, useTranslation } from 'react-i18next'
+import useAsync from '@/shared/hooks/use-async'
+import { ExposedSettings } from '../../../../../../types/exposed-settings'
+import { useLocation } from '@/shared/hooks/use-location'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
+import classnames from 'classnames'
 
 type ReconfirmationInfoProps = {
   userEmailData: UserEmailData
@@ -17,6 +29,48 @@ function ReconfirmationInfo({ userEmailData }: ReconfirmationInfoProps) {
   ) as string
   const reconfirmedViaSAML = getMeta('ol-reconfirmedViaSAML') as string
 
+  const { t } = useTranslation()
+  const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings
+  const { error, isLoading, isError, isSuccess, runAsync } = useAsync()
+  const { state, setLoading: setUserEmailsContextLoading } =
+    useUserEmailsContext()
+  const [hasSent, setHasSent] = useState(false)
+  const [isPending, setIsPending] = useState(false)
+  const location = useLocation()
+  const ssoAvailable = Boolean(
+    ssoAvailableForInstitution(userEmailData.affiliation?.institution ?? null)
+  )
+
+  const handleRequestReconfirmation = () => {
+    if (userEmailData.affiliation?.institution && ssoAvailable) {
+      setIsPending(true)
+      location.assign(
+        `${samlInitPath}?university_id=${userEmailData.affiliation.institution.id}&reconfirm=/user/settings`
+      )
+    } else {
+      runAsync(
+        postJSON('/user/emails/send-reconfirmation', {
+          body: {
+            email: userEmailData.email,
+          },
+        })
+      ).catch(debugConsole.error)
+    }
+  }
+
+  useEffect(() => {
+    setUserEmailsContextLoading(isLoading)
+  }, [setUserEmailsContextLoading, isLoading])
+
+  useLayoutEffect(() => {
+    if (isSuccess) {
+      setHasSent(true)
+    }
+  }, [isSuccess])
+
+  const rateLimited =
+    isError && error instanceof FetchError && error.response?.status === 429
+
   if (!userEmailData.affiliation) {
     return null
   }
@@ -26,53 +80,189 @@ function ReconfirmationInfo({ userEmailData }: ReconfirmationInfoProps) {
     userEmailData.samlProviderId === reconfirmedViaSAML
   ) {
     return (
-      <ReconfirmationInfoContentWrapper asAlertInfo>
-        <ReconfirmationInfoSuccess
-          institution={userEmailData.affiliation.institution}
-        />
-      </ReconfirmationInfoContentWrapper>
+      <RowWrapper>
+        <ColWrapper md={12}>
+          <NotificationWrapper
+            type="info"
+            content={
+              <ReconfirmationInfoSuccess
+                institution={userEmailData.affiliation.institution}
+              />
+            }
+            bs3Props={{ className: 'settings-reconfirm-info small' }}
+          />
+        </ColWrapper>
+      </RowWrapper>
     )
   }
 
   if (userEmailData.affiliation.inReconfirmNotificationPeriod) {
     return (
-      <ReconfirmationInfoContentWrapper
-        asAlertInfo={reconfirmationRemoveEmail === userEmailData.email}
-      >
-        <ReconfirmationInfoPrompt
-          institution={userEmailData.affiliation.institution}
-          primary={userEmailData.default}
-          email={userEmailData.email}
-        />
-      </ReconfirmationInfoContentWrapper>
+      <RowWrapper>
+        <ColWrapper md={12}>
+          {isBootstrap5 ? (
+            <NotificationWrapper
+              type="info"
+              content={
+                <>
+                  {hasSent ? (
+                    <Trans
+                      i18nKey="please_check_your_inbox_to_confirm"
+                      values={{
+                        institutionName:
+                          userEmailData.affiliation.institution.name,
+                      }}
+                      shouldUnescape
+                      tOptions={{ interpolation: { escapeValue: true } }}
+                      components={
+                        /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
+                        [<strong />]
+                      }
+                    />
+                  ) : (
+                    <ReconfirmationInfoPromptText
+                      institutionName={
+                        userEmailData.affiliation.institution.name
+                      }
+                      primary={userEmailData.default}
+                    />
+                  )}
+                  <br />
+                  {isError && (
+                    <div className="text-danger">
+                      {rateLimited
+                        ? t('too_many_requests')
+                        : t('generic_something_went_wrong')}
+                    </div>
+                  )}
+                </>
+              }
+              action={
+                hasSent ? (
+                  <>
+                    {isLoading ? (
+                      <>
+                        <Icon type="refresh" spin fw /> {t('sending')}...
+                      </>
+                    ) : (
+                      <ButtonWrapper
+                        className="btn-inline-link"
+                        disabled={state.isLoading}
+                        onClick={handleRequestReconfirmation}
+                      >
+                        {t('resend_confirmation_email')}
+                      </ButtonWrapper>
+                    )}
+                  </>
+                ) : (
+                  <ButtonWrapper
+                    variant="secondary"
+                    disabled={state.isLoading || isPending}
+                    onClick={handleRequestReconfirmation}
+                    bs3Props={{ bsStyle: 'info' }}
+                  >
+                    {isLoading ? (
+                      <>
+                        <Icon type="refresh" spin fw /> {t('sending')}...
+                      </>
+                    ) : (
+                      t('confirm_affiliation')
+                    )}
+                  </ButtonWrapper>
+                )
+              }
+            />
+          ) : (
+            <div
+              className={classnames('settings-reconfirm-info', 'small', {
+                'alert alert-info':
+                  reconfirmationRemoveEmail === userEmailData.email,
+              })}
+            >
+              {hasSent ? (
+                <div>
+                  <Trans
+                    i18nKey="please_check_your_inbox_to_confirm"
+                    values={{
+                      institutionName:
+                        userEmailData.affiliation.institution.name,
+                    }}
+                    shouldUnescape
+                    tOptions={{ interpolation: { escapeValue: true } }}
+                    components={
+                      /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
+                      [<strong />]
+                    }
+                  />{' '}
+                  {isLoading ? (
+                    <>
+                      <Icon type="refresh" spin fw /> {t('sending')}...
+                    </>
+                  ) : (
+                    <ButtonWrapper
+                      className="btn-inline-link"
+                      disabled={state.isLoading}
+                      onClick={handleRequestReconfirmation}
+                    >
+                      {t('resend_confirmation_email')}
+                    </ButtonWrapper>
+                  )}
+                  <br />
+                  {isError && (
+                    <div className="text-danger">
+                      {rateLimited
+                        ? t('too_many_requests')
+                        : t('generic_something_went_wrong')}
+                    </div>
+                  )}
+                </div>
+              ) : (
+                <>
+                  <div>
+                    <ReconfirmationInfoPromptText
+                      institutionName={
+                        userEmailData.affiliation.institution.name
+                      }
+                      primary={userEmailData.default}
+                      icon={
+                        <Icon type="warning" className="me-1 icon-warning" />
+                      }
+                    />
+                  </div>
+                  <div className="setting-reconfirm-info-right">
+                    <ButtonWrapper
+                      variant="secondary"
+                      disabled={state.isLoading || isPending}
+                      onClick={handleRequestReconfirmation}
+                      bs3Props={{ bsStyle: 'info' }}
+                    >
+                      {isLoading ? (
+                        <>
+                          <Icon type="refresh" spin fw /> {t('sending')}...
+                        </>
+                      ) : (
+                        t('confirm_affiliation')
+                      )}
+                    </ButtonWrapper>
+                    <br />
+                    {isError && (
+                      <div className="text-danger">
+                        {rateLimited
+                          ? t('too_many_requests')
+                          : t('generic_something_went_wrong')}
+                      </div>
+                    )}
+                  </div>
+                </>
+              )}
+            </div>
+          )}
+        </ColWrapper>
+      </RowWrapper>
     )
   }
 
   return null
 }
 
-type ReconfirmationInfoContentWrapperProps = {
-  asAlertInfo: boolean
-  children: ReactNode
-}
-
-function ReconfirmationInfoContentWrapper({
-  asAlertInfo,
-  children,
-}: ReconfirmationInfoContentWrapperProps) {
-  return (
-    <RowWrapper>
-      <ColWrapper md={12}>
-        <div
-          className={classNames('settings-reconfirm-info', 'small', {
-            'alert alert-info': asAlertInfo,
-          })}
-        >
-          {children}
-        </div>
-      </ColWrapper>
-    </RowWrapper>
-  )
-}
-
 export default ReconfirmationInfo

+ 51 - 0
services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt-text.tsx

@@ -0,0 +1,51 @@
+import { Trans, useTranslation } from 'react-i18next'
+import { Institution } from '../../../../../../../types/institution'
+
+type ReconfirmationInfoPromptTextProps = {
+  primary: boolean
+  institutionName: Institution['name']
+  icon?: React.ReactElement // BS3 only
+}
+
+function ReconfirmationInfoPromptText({
+  primary,
+  institutionName,
+  icon,
+}: ReconfirmationInfoPromptTextProps) {
+  const { t } = useTranslation()
+
+  return (
+    <>
+      {icon}
+      <Trans
+        i18nKey="are_you_still_at"
+        values={{
+          institutionName,
+        }}
+        shouldUnescape
+        tOptions={{ interpolation: { escapeValue: true } }}
+        components={
+          /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
+          [<strong />]
+        }
+      />{' '}
+      <Trans
+        i18nKey="please_reconfirm_institutional_email"
+        components={
+          /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
+          [<span />]
+        }
+      />{' '}
+      <a
+        href="/learn/how-to/Institutional_Email_Reconfirmation"
+        target="_blank"
+      >
+        {t('learn_more')}
+      </a>
+      <br />
+      {primary ? <i>{t('need_to_add_new_primary_before_remove')}</i> : null}
+    </>
+  )
+}
+
+export default ReconfirmationInfoPromptText

+ 0 - 186
services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-prompt.tsx

@@ -1,186 +0,0 @@
-import { useState, useEffect, useLayoutEffect } from 'react'
-import useAsync from '../../../../../shared/hooks/use-async'
-import { FetchError, postJSON } from '../../../../../infrastructure/fetch-json'
-import { Trans, useTranslation } from 'react-i18next'
-import { Institution } from '../../../../../../../types/institution'
-import { Button } from 'react-bootstrap'
-import { useUserEmailsContext } from '../../../context/user-email-context'
-import getMeta from '../../../../../utils/meta'
-import { ExposedSettings } from '../../../../../../../types/exposed-settings'
-import { ssoAvailableForInstitution } from '../../../utils/sso'
-import Icon from '../../../../../shared/components/icon'
-import { useLocation } from '../../../../../shared/hooks/use-location'
-import { debugConsole } from '@/utils/debugging'
-
-type ReconfirmationInfoPromptProps = {
-  email: string
-  primary: boolean
-  institution: Institution
-}
-
-function ReconfirmationInfoPrompt({
-  email,
-  primary,
-  institution,
-}: ReconfirmationInfoPromptProps) {
-  const { t } = useTranslation()
-  const { samlInitPath } = getMeta('ol-ExposedSettings') as ExposedSettings
-  const { error, isLoading, isError, isSuccess, runAsync } = useAsync()
-  const { state, setLoading: setUserEmailsContextLoading } =
-    useUserEmailsContext()
-  const [isPending, setIsPending] = useState(false)
-  const [hasSent, setHasSent] = useState(false)
-  const ssoAvailable = Boolean(ssoAvailableForInstitution(institution))
-  const location = useLocation()
-
-  useEffect(() => {
-    setUserEmailsContextLoading(isLoading)
-  }, [setUserEmailsContextLoading, isLoading])
-
-  useLayoutEffect(() => {
-    if (isSuccess) {
-      setHasSent(true)
-    }
-  }, [isSuccess])
-
-  const handleRequestReconfirmation = () => {
-    if (ssoAvailable) {
-      setIsPending(true)
-      location.assign(
-        `${samlInitPath}?university_id=${institution.id}&reconfirm=/user/settings`
-      )
-    } else {
-      runAsync(
-        postJSON('/user/emails/send-reconfirmation', {
-          body: {
-            email,
-          },
-        })
-      ).catch(debugConsole.error)
-    }
-  }
-
-  const rateLimited =
-    isError && error instanceof FetchError && error.response?.status === 429
-
-  if (hasSent) {
-    return (
-      <div>
-        <Trans
-          i18nKey="please_check_your_inbox_to_confirm"
-          values={{
-            institutionName: institution.name,
-          }}
-          shouldUnescape
-          tOptions={{ interpolation: { escapeValue: true } }}
-          components={
-            /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
-            [<strong />]
-          }
-        />{' '}
-        {isLoading ? (
-          <>
-            <Icon type="refresh" spin fw /> {t('sending')}...
-          </>
-        ) : (
-          <Button
-            className="btn-inline-link"
-            disabled={state.isLoading}
-            onClick={handleRequestReconfirmation}
-          >
-            {t('resend_confirmation_email')}
-          </Button>
-        )}
-        <br />
-        {isError && (
-          <div className="text-danger">
-            {rateLimited
-              ? t('too_many_requests')
-              : t('generic_something_went_wrong')}
-          </div>
-        )}
-      </div>
-    )
-  }
-
-  return (
-    <>
-      <div>
-        <ReconfirmationInfoPromptText
-          institutionName={institution.name}
-          primary={primary}
-        />
-      </div>
-      <div className="setting-reconfirm-info-right">
-        <Button
-          bsStyle="info"
-          disabled={state.isLoading || isPending}
-          onClick={handleRequestReconfirmation}
-        >
-          {isLoading ? (
-            <>
-              <Icon type="refresh" spin fw /> {t('sending')}...
-            </>
-          ) : (
-            t('confirm_affiliation')
-          )}
-        </Button>
-        <br />
-        {isError && (
-          <div className="text-danger">
-            {rateLimited
-              ? t('too_many_requests')
-              : t('generic_something_went_wrong')}
-          </div>
-        )}
-      </div>
-    </>
-  )
-}
-
-type ReconfirmationInfoPromptTextProps = {
-  primary: boolean
-  institutionName: Institution['name']
-}
-
-function ReconfirmationInfoPromptText({
-  primary,
-  institutionName,
-}: ReconfirmationInfoPromptTextProps) {
-  const { t } = useTranslation()
-
-  return (
-    <div>
-      <Icon type="warning" className="me-1 icon-warning" />
-      <Trans
-        i18nKey="are_you_still_at"
-        values={{
-          institutionName,
-        }}
-        shouldUnescape
-        tOptions={{ interpolation: { escapeValue: true } }}
-        components={
-          /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
-          [<strong />]
-        }
-      />{' '}
-      <Trans
-        i18nKey="please_reconfirm_institutional_email"
-        components={
-          /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
-          [<span />]
-        }
-      />{' '}
-      <a
-        href="/learn/how-to/Institutional_Email_Reconfirmation"
-        target="_blank"
-      >
-        {t('learn_more')}
-      </a>
-      <br />
-      {primary ? <i>{t('need_to_add_new_primary_before_remove')}</i> : null}
-    </div>
-  )
-}
-
-export default ReconfirmationInfoPrompt

+ 10 - 8
services/web/frontend/js/features/settings/components/emails/reconfirmation-info/reconfirmation-info-success.tsx

@@ -14,14 +14,16 @@ function ReconfirmationInfoSuccess({
 
   return (
     <div className={className}>
-      <Trans
-        i18nKey="your_affiliation_is_confirmed"
-        values={{ institutionName: institution.name }}
-        shouldUnescape
-        tOptions={{ interpolation: { escapeValue: true } }}
-        components={[<strong />]} // eslint-disable-line react/jsx-key
-      />{' '}
-      {t('thank_you_exclamation')}
+      <div>
+        <Trans
+          i18nKey="your_affiliation_is_confirmed"
+          values={{ institutionName: institution.name }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          components={[<strong />]} // eslint-disable-line react/jsx-key
+        />{' '}
+        {t('thank_you_exclamation')}
+      </div>
     </div>
   )
 }

+ 72 - 47
services/web/frontend/js/features/settings/components/emails/sso-alert.tsx

@@ -1,8 +1,8 @@
 import { useState } from 'react'
 import { useTranslation, Trans } from 'react-i18next'
-import { Alert } from 'react-bootstrap'
 import Icon from '../../../../shared/components/icon'
 import getMeta from '../../../../utils/meta'
+import NotificationWrapper from '@/features/ui/components/bootstrap-5/notification-wrapper'
 
 type InstitutionLink = {
   universityName: string
@@ -36,18 +36,28 @@ export function SSOAlert() {
 
   if (samlError) {
     return !errorClosed ? (
-      <Alert bsStyle="danger" className="mb-0" onDismiss={handleErrorClosed}>
-        <p className="text-center">
-          <Icon
-            type="exclamation-triangle"
-            accessibilityLabel={t('generic_something_went_wrong')}
-          />{' '}
-          {samlError.translatedMessage
-            ? samlError.translatedMessage
-            : samlError.message}
-        </p>
-        {samlError.tryAgain && <p className="text-center">{t('try_again')}</p>}
-      </Alert>
+      <NotificationWrapper
+        type="error"
+        content={
+          <>
+            {samlError.translatedMessage
+              ? samlError.translatedMessage
+              : samlError.message}
+            {samlError.tryAgain && <p>{t('try_again')}</p>}
+          </>
+        }
+        isDismissible
+        onDismiss={handleErrorClosed}
+        bs3Props={{
+          icon: (
+            <Icon
+              type="exclamation-triangle"
+              accessibilityLabel={t('generic_something_went_wrong')}
+            />
+          ),
+          className: 'mb-0 text-center',
+        }}
+      />
     ) : null
   }
 
@@ -58,40 +68,43 @@ export function SSOAlert() {
   return (
     <>
       {!infoClosed && (
-        <Alert bsStyle="info" className="mb-0" onDismiss={handleInfoClosed}>
-          <p className="text-center">
-            <Trans
-              i18nKey="institution_acct_successfully_linked_2"
-              components={[<strong />]} // eslint-disable-line react/jsx-key
-              values={{ institutionName: institutionLinked.universityName }}
-              shouldUnescape
-              tOptions={{ interpolation: { escapeValue: true } }}
-            />
-          </p>
-          {institutionLinked.hasEntitlement && (
-            <p className="text-center">
-              <Trans
-                i18nKey="this_grants_access_to_features_2"
-                components={[<strong />]} // eslint-disable-line react/jsx-key
-                values={{ featureType: t('professional') }}
-                shouldUnescape
-                tOptions={{ interpolation: { escapeValue: true } }}
-              />
-            </p>
-          )}
-        </Alert>
+        <NotificationWrapper
+          type="info"
+          content={
+            <>
+              <p>
+                <Trans
+                  i18nKey="institution_acct_successfully_linked_2"
+                  components={[<strong />]} // eslint-disable-line react/jsx-key
+                  values={{ institutionName: institutionLinked.universityName }}
+                  shouldUnescape
+                  tOptions={{ interpolation: { escapeValue: true } }}
+                />
+              </p>
+              {institutionLinked.hasEntitlement && (
+                <p>
+                  <Trans
+                    i18nKey="this_grants_access_to_features_2"
+                    components={[<strong />]} // eslint-disable-line react/jsx-key
+                    values={{ featureType: t('professional') }}
+                    shouldUnescape
+                    tOptions={{ interpolation: { escapeValue: true } }}
+                  />
+                </p>
+              )}
+            </>
+          }
+          isDismissible
+          onDismiss={handleInfoClosed}
+          bs3Props={{
+            className: 'mb-0 text-center',
+          }}
+        />
       )}
       {!warningClosed && institutionEmailNonCanonical && (
-        <Alert
-          bsStyle="warning"
-          className="mb-0"
-          onDismiss={handleWarningClosed}
-        >
-          <p className="text-center">
-            <Icon
-              type="exclamation-triangle"
-              accessibilityLabel={t('generic_something_went_wrong')}
-            />{' '}
+        <NotificationWrapper
+          type="warning"
+          content={
             <Trans
               i18nKey="in_order_to_match_institutional_metadata_2"
               components={[<strong />]} // eslint-disable-line react/jsx-key
@@ -99,8 +112,20 @@ export function SSOAlert() {
               shouldUnescape
               tOptions={{ interpolation: { escapeValue: true } }}
             />
-          </p>
-        </Alert>
+          }
+          isDismissible
+          onDismiss={handleWarningClosed}
+          bs3Props={{
+            icon: (
+              <Icon
+                type="exclamation-triangle"
+                accessibilityLabel={t('generic_something_went_wrong')}
+                fw
+              />
+            ),
+            className: 'text-center',
+          }}
+        />
       )}
     </>
   )

+ 13 - 8
services/web/frontend/js/features/settings/components/leave/modal-form-error.tsx

@@ -1,8 +1,8 @@
-import { Alert } from 'react-bootstrap'
 import { useTranslation, Trans } from 'react-i18next'
 import getMeta from '../../../../utils/meta'
 import { FetchError } from '../../../../infrastructure/fetch-json'
 import { ExposedSettings } from '../../../../../../types/exposed-settings'
+import NotificationWrapper from '@/features/ui/components/bootstrap-5/notification-wrapper'
 
 type LeaveModalFormErrorProps = {
   error: FetchError
@@ -32,15 +32,20 @@ function LeaveModalFormError({ error }: LeaveModalFormErrorProps) {
   }
 
   return (
-    <Alert bsStyle="danger">
-      {errorMessage}
-      {errorTip ? (
+    <NotificationWrapper
+      type="error"
+      content={
         <>
-          <br />
-          {errorTip}
+          {errorMessage}
+          {errorTip ? (
+            <>
+              <br />
+              {errorTip}
+            </>
+          ) : null}
         </>
-      ) : null}
-    </Alert>
+      }
+    />
   )
 }
 

+ 21 - 15
services/web/frontend/js/features/settings/components/leavers-survey-alert.tsx

@@ -1,9 +1,9 @@
 import { useEffect } from 'react'
-import { Alert } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import usePersistedState from '../../../shared/hooks/use-persisted-state'
 import { useUserEmailsContext } from '../context/user-email-context'
 import { sendMB } from '../../../infrastructure/event-tracking'
+import NotificationWrapper from '@/features/ui/components/bootstrap-5/notification-wrapper'
 
 function sendMetrics(segmentation: 'view' | 'click' | 'close') {
   sendMB('institutional-leavers-survey-notification', { type: segmentation })
@@ -47,19 +47,25 @@ export function LeaversSurveyAlert() {
   }
 
   return (
-    <Alert className="mb-0" bsStyle="info" onDismiss={handleDismiss}>
-      <p>
-        <strong>{t('limited_offer')}</strong>
-        {`: ${t('institutional_leavers_survey_notification')} `}
-        <a
-          href="https://docs.google.com/forms/d/e/1FAIpQLSfYdeeoY5p1d31r5iUx1jw0O-Gd66vcsBi_Ntu3lJRMjV2EJA/viewform"
-          target="_blank"
-          rel="noopener noreferrer"
-          onClick={handleLinkClick}
-        >
-          {t('take_short_survey')}
-        </a>
-      </p>
-    </Alert>
+    <NotificationWrapper
+      type="info"
+      content={
+        <>
+          <strong>{t('limited_offer')}</strong>
+          {`: ${t('institutional_leavers_survey_notification')} `}
+          <a
+            href="https://docs.google.com/forms/d/e/1FAIpQLSfYdeeoY5p1d31r5iUx1jw0O-Gd66vcsBi_Ntu3lJRMjV2EJA/viewform"
+            target="_blank"
+            rel="noopener noreferrer"
+            onClick={handleLinkClick}
+          >
+            {t('take_short_survey')}
+          </a>
+        </>
+      }
+      isDismissible
+      onDismiss={handleDismiss}
+      className="mb-0"
+    />
   )
 }

+ 9 - 5
services/web/frontend/js/features/settings/components/linking-section.tsx

@@ -1,5 +1,4 @@
 import { useState, ElementType } from 'react'
-import { Alert } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
 import { useSSOContext, SSOSubscription } from '../context/sso-context'
@@ -7,6 +6,7 @@ import { SSOLinkingWidget } from './linking/sso-widget'
 import getMeta from '../../../utils/meta'
 import { useBroadcastUser } from '@/shared/hooks/user-channel/use-broadcast-user'
 import { useSplitTestContext } from '@/shared/context/split-test-context'
+import NotificationWrapper from '@/features/ui/components/bootstrap-5/notification-wrapper'
 
 function LinkingSection() {
   useBroadcastUser()
@@ -126,7 +126,10 @@ function LinkingSection() {
             {t('project_synchronisation')}
           </h3>
           {projectSyncSuccessMessage ? (
-            <Alert bsStyle="success">{projectSyncSuccessMessage}</Alert>
+            <NotificationWrapper
+              type="success"
+              content={projectSyncSuccessMessage}
+            />
           ) : null}
           <div className="settings-widgets-container">
             {allIntegrationLinkingWidgets.map(
@@ -167,9 +170,10 @@ function LinkingSection() {
             {t('linked_accounts')}
           </h3>
           {ssoErrorMessage ? (
-            <Alert bsStyle="danger">
-              {t('sso_link_error')}: {ssoErrorMessage}
-            </Alert>
+            <NotificationWrapper
+              type="error"
+              content={`${t('sso_link_error')}: ${ssoErrorMessage}`}
+            />
           ) : null}
           <div className="settings-widgets-container">
             {Object.values(subscriptions).map(

+ 36 - 32
services/web/frontend/js/features/settings/components/password-section.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useState } from 'react'
-import { Alert, ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
+import { ControlLabel, FormControl, FormGroup } from 'react-bootstrap'
 import { Trans, useTranslation } from 'react-i18next'
 import {
   getUserFacingMessage,
@@ -11,6 +11,7 @@ import { ExposedSettings } from '../../../../../types/exposed-settings'
 import { PasswordStrengthOptions } from '../../../../../types/password-strength-options'
 import useAsync from '../../../shared/hooks/use-async'
 import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
+import NotificationWrapper from '@/features/ui/components/bootstrap-5/notification-wrapper'
 
 type PasswordUpdateResult = {
   message?: {
@@ -156,41 +157,44 @@ function PasswordForm() {
       />
       {isSuccess && data?.message?.text ? (
         <FormGroup>
-          <Alert bsStyle="success">{data.message.text}</Alert>
+          <NotificationWrapper type="success" content={data.message.text} />
         </FormGroup>
       ) : null}
       {isError ? (
         <FormGroup>
-          <Alert bsStyle="danger">
-            {getErrorMessageKey(error) === 'password-must-be-strong' ? (
-              <>
-                <Trans
-                  i18nKey="password_was_detected_on_a_public_list_of_known_compromised_passwords"
-                  components={[
-                    /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
-                    <a
-                      href="https://haveibeenpwned.com/passwords"
-                      target="_blank"
-                      rel="noreferrer noopener"
-                    />,
-                  ]}
-                />
-                . {t('use_a_different_password')}.
-              </>
-            ) : getErrorMessageKey(error) === 'password-contains-email' ? (
-              <>
-                {t('invalid_password_contains_email')}.{' '}
-                {t('use_a_different_password')}.
-              </>
-            ) : getErrorMessageKey(error) === 'password-too-similar' ? (
-              <>
-                {t('invalid_password_too_similar')}.{' '}
-                {t('use_a_different_password')}.
-              </>
-            ) : (
-              getUserFacingMessage(error)
-            )}
-          </Alert>
+          <NotificationWrapper
+            type="error"
+            content={
+              getErrorMessageKey(error) === 'password-must-be-strong' ? (
+                <>
+                  <Trans
+                    i18nKey="password_was_detected_on_a_public_list_of_known_compromised_passwords"
+                    components={[
+                      /* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
+                      <a
+                        href="https://haveibeenpwned.com/passwords"
+                        target="_blank"
+                        rel="noreferrer noopener"
+                      />,
+                    ]}
+                  />
+                  . {t('use_a_different_password')}.
+                </>
+              ) : getErrorMessageKey(error) === 'password-contains-email' ? (
+                <>
+                  {t('invalid_password_contains_email')}.{' '}
+                  {t('use_a_different_password')}.
+                </>
+              ) : getErrorMessageKey(error) === 'password-too-similar' ? (
+                <>
+                  {t('invalid_password_too_similar')}.{' '}
+                  {t('use_a_different_password')}.
+                </>
+              ) : (
+                getUserFacingMessage(error) ?? ''
+              )
+            }
+          />
         </FormGroup>
       ) : null}
       <ButtonWrapper

+ 1 - 1
services/web/frontend/js/features/ui/components/bootstrap-5/notification-wrapper.tsx

@@ -5,7 +5,7 @@ import classnames from 'classnames'
 
 type NotificationWrapperProps = React.ComponentProps<typeof Notification> & {
   bs3Props?: {
-    icon: React.ReactElement
+    icon?: React.ReactElement
     className?: string
   }
 }

+ 1 - 1
services/web/frontend/js/shared/components/notification.tsx

@@ -14,7 +14,7 @@ export type NotificationProps = {
   action?: React.ReactElement
   ariaLive?: 'polite' | 'off' | 'assertive'
   className?: string
-  content: React.ReactElement | string
+  content: React.ReactNode
   customIcon?: React.ReactElement
   disclaimer?: React.ReactElement | string
   isDismissible?: boolean

+ 2 - 0
services/web/frontend/stylesheets/app/account-settings.less

@@ -196,6 +196,7 @@ tbody > tr.affiliations-table-warning-row > td {
   }
 }
 
+// Should not be migrated to BS5
 .settings-reconfirm-info {
   display: flex;
   justify-content: space-between;
@@ -213,6 +214,7 @@ tbody > tr.affiliations-table-warning-row > td {
   }
 }
 
+// Should not be migrated to BS5
 .setting-reconfirm-info-right {
   white-space: nowrap;
 }

+ 4 - 4
services/web/frontend/stylesheets/bootstrap-5/components/notifications.scss

@@ -2,7 +2,7 @@
   // will be deprecated once notifications moved to use .notification (see below)
   flex-grow: 1;
   width: 90%;
-  @media (min-width: var(--bs-breakpoint-md)) {
+  @include media-breakpoint-up(md) {
     width: auto;
   }
 }
@@ -13,7 +13,7 @@
   // will be deprecated once notifications moved to use .notification (see below)
   margin-top: calc($line-height-computed / 2); // match paragraph padding
   order: 1;
-  @media (min-width: var(--bs-breakpoint-md)) {
+  @include media-breakpoint-up(md) {
     margin-top: 0;
     order: 0;
     padding-left: $spacing-05;
@@ -43,7 +43,7 @@
     }
   }
 
-  @media (min-width: var(--bs-breakpoint-md)) {
+  @include media-breakpoint-up(md) {
     width: auto;
   }
 }
@@ -167,7 +167,7 @@
     }
   }
 
-  @media (min-width: var(--bs-breakpoint-md)) {
+  @include media-breakpoint-up(md) {
     &:not(.notification-cta-below-content) {
       .notification-content-and-cta {
         flex-wrap: nowrap;

+ 3 - 0
services/web/test/frontend/features/settings/components/emails/reconfirmation-info.test.tsx

@@ -28,6 +28,9 @@ describe('<ReconfirmationInfo/>', function () {
 
   beforeEach(function () {
     window.metaAttributesCache = window.metaAttributesCache || new Map()
+    window.metaAttributesCache.set('ol-ExposedSettings', {
+      samlInitPath: '/saml',
+    })
     fetchMock.get('/user/emails?ensureAffiliation=true', [])
     assignStub = sinon.stub()
     this.locationStub = sinon.stub(useLocationModule, 'useLocation').returns({