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

Merge pull request #17990 from overleaf/rd-button-links

[web] Migrating buttons to Bootstrap 5 on the Account Settings page

GitOrigin-RevId: c9dfa9b1dee50f4c0b30abf8ac464e53cf98db95
Rebeka Dekany 2 лет назад
Родитель
Сommit
898acab307
25 измененных файлов с 277 добавлено и 146 удалено
  1. 2 1
      services/web/frontend/js/features/settings/components/account-info-section.tsx
  2. 13 10
      services/web/frontend/js/features/settings/components/emails/actions/make-primary/confirmation-modal.tsx
  3. 1 1
      services/web/frontend/js/features/settings/components/emails/actions/make-primary/make-primary.tsx
  4. 12 7
      services/web/frontend/js/features/settings/components/emails/actions/make-primary/primary-button.tsx
  5. 2 1
      services/web/frontend/js/features/settings/components/emails/add-email.tsx
  6. 8 6
      services/web/frontend/js/features/settings/components/emails/add-email/add-another-email-btn.tsx
  7. 16 8
      services/web/frontend/js/features/settings/components/emails/add-email/add-new-email-btn.tsx
  8. 11 6
      services/web/frontend/js/features/settings/components/emails/add-email/email-affiliated-with-institution.tsx
  9. 6 5
      services/web/frontend/js/features/settings/components/emails/add-email/sso-linking-info.tsx
  10. 7 3
      services/web/frontend/js/features/settings/components/emails/add-email/university-name.tsx
  11. 23 12
      services/web/frontend/js/features/settings/components/emails/institution-and-role.tsx
  12. 9 3
      services/web/frontend/js/features/settings/components/emails/reconfirmation-info.tsx
  13. 6 6
      services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx
  14. 6 5
      services/web/frontend/js/features/settings/components/emails/row.tsx
  15. 11 2
      services/web/frontend/js/features/settings/components/leave-section.tsx
  16. 9 9
      services/web/frontend/js/features/settings/components/leave/modal-content.tsx
  17. 16 13
      services/web/frontend/js/features/settings/components/linking/enable-widget.tsx
  18. 40 23
      services/web/frontend/js/features/settings/components/linking/integration-widget.tsx
  19. 32 18
      services/web/frontend/js/features/settings/components/linking/sso-widget.tsx
  20. 2 1
      services/web/frontend/js/features/settings/components/password-section.tsx
  21. 5 3
      services/web/frontend/js/features/settings/components/security-section.tsx
  22. 3 1
      services/web/frontend/js/features/ui/components/bootstrap-5/wrappers/button-wrapper.tsx
  23. 1 0
      services/web/frontend/js/features/ui/components/types/button-props.ts
  24. 2 2
      services/web/frontend/stylesheets/bootstrap-5/base/links.scss
  25. 34 0
      services/web/frontend/stylesheets/bootstrap-5/components/button.scss

+ 2 - 1
services/web/frontend/js/features/settings/components/account-info-section.tsx

@@ -124,7 +124,8 @@ function AccountInfoSection() {
             type="submit"
             variant="primary"
             form="account-info-form"
-            isLoading={isLoading || !isFormValid}
+            disabled={!isFormValid}
+            isLoading={isLoading}
             bs3Props={{
               loading: isLoading ? `${t('saving')}…` : t('update'),
             }}

+ 13 - 10
services/web/frontend/js/features/settings/components/emails/actions/make-primary/confirmation-modal.tsx

@@ -1,7 +1,8 @@
 import { useTranslation, Trans } from 'react-i18next'
-import { Modal, Button } from 'react-bootstrap'
+import { Modal } from 'react-bootstrap'
 import AccessibleModal from '../../../../../../shared/components/accessible-modal'
 import { MergeAndOverride } from '../../../../../../../../types/utils'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
 type ConfirmationModalProps = MergeAndOverride<
   React.ComponentProps<typeof AccessibleModal>,
@@ -40,22 +41,24 @@ function ConfirmationModal({
         <p className="mb-0">{t('log_in_with_primary_email_address')}</p>
       </Modal.Body>
       <Modal.Footer>
-        <Button
-          bsStyle={null}
-          className="btn-secondary-info btn-secondary"
+        <ButtonWrapper
+          variant="secondary"
           onClick={onHide}
+          bs3Props={{
+            bsStyle: null,
+            className: 'btn-secondary-info btn-secondary',
+          }}
         >
           {t('cancel')}
-        </Button>
-        <Button
-          type="button"
-          bsStyle={null}
-          className="btn-primary"
+        </ButtonWrapper>
+        <ButtonWrapper
+          variant="primary"
           disabled={isConfirmDisabled}
           onClick={onConfirm}
+          bs3Props={{ bsStyle: null, className: 'btn-primary' }}
         >
           {t('confirm')}
-        </Button>
+        </ButtonWrapper>
       </Modal.Footer>
     </AccessibleModal>
   )

+ 1 - 1
services/web/frontend/js/features/settings/components/emails/actions/make-primary/make-primary.tsx

@@ -82,7 +82,7 @@ function MakePrimary({ userEmailData, makePrimaryAsync }: MakePrimaryProps) {
   return (
     <>
       {makePrimaryAsync.isLoading ? (
-        <PrimaryButton disabled>
+        <PrimaryButton disabled isLoading={state.isLoading}>
           {t('processing_uppercase')}&hellip;
         </PrimaryButton>
       ) : (

+ 12 - 7
services/web/frontend/js/features/settings/components/emails/actions/make-primary/primary-button.tsx

@@ -1,19 +1,24 @@
 import ButtonWrapper, {
   ButtonWrapperProps,
 } from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
-import { bsVersion } from '@/features/utils/bootstrap-5'
 
-function PrimaryButton({ children, disabled, onClick }: ButtonWrapperProps) {
+function PrimaryButton({
+  children,
+  disabled,
+  isLoading,
+  onClick,
+}: ButtonWrapperProps) {
   return (
     <ButtonWrapper
       size="small"
-      disabled={disabled}
+      disabled={disabled && !isLoading}
+      isLoading={isLoading}
       onClick={onClick}
       variant="secondary"
-      bs3Props={{ bsStyle: null }}
-      className={bsVersion({
-        bs3: 'btn-secondary btn-secondary-info',
-      })}
+      bs3Props={{
+        bsStyle: null,
+        className: 'btn-secondary btn-secondary-info',
+      }}
     >
       {children}
     </ButtonWrapper>

+ 2 - 1
services/web/frontend/js/features/settings/components/emails/add-email.tsx

@@ -214,7 +214,8 @@ function AddEmail() {
             >
               <AddNewEmailBtn
                 email={newEmail}
-                disabled={isLoading || state.isLoading}
+                disabled={state.isLoading}
+                isLoading={isLoading}
                 onClick={handleAddNewEmail}
               />
             </Cell>

+ 8 - 6
services/web/frontend/js/features/settings/components/emails/add-email/add-another-email-btn.tsx

@@ -1,18 +1,20 @@
 import { useTranslation } from 'react-i18next'
-import { Button, ButtonProps } from 'react-bootstrap'
+import ButtonWrapper, {
+  ButtonWrapperProps,
+} from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
-function AddAnotherEmailBtn({ onClick, ...props }: ButtonProps) {
+function AddAnotherEmailBtn({ onClick, ...props }: ButtonWrapperProps) {
   const { t } = useTranslation()
 
   return (
-    <Button
-      className="btn-inline-link"
+    <ButtonWrapper
+      variant="link"
       onClick={onClick}
       {...props}
-      bsStyle={null}
+      bs3Props={{ bsStyle: null, className: 'btn-inline-link' }}
     >
       {t('add_another_email')}
-    </Button>
+    </ButtonWrapper>
   )
 }
 

+ 16 - 8
services/web/frontend/js/features/settings/components/emails/add-email/add-new-email-btn.tsx

@@ -1,5 +1,7 @@
 import { useTranslation } from 'react-i18next'
-import { Button, ButtonProps } from 'react-bootstrap'
+import ButtonWrapper, {
+  ButtonWrapperProps,
+} from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
 const isValidEmail = (email: string) => {
   return Boolean(email)
@@ -7,20 +9,26 @@ const isValidEmail = (email: string) => {
 
 type AddNewEmailColProps = {
   email: string
-} & ButtonProps
+} & ButtonWrapperProps
 
-function AddNewEmailBtn({ email, disabled, ...props }: AddNewEmailColProps) {
+function AddNewEmailBtn({
+  email,
+  disabled,
+  isLoading,
+  ...props
+}: AddNewEmailColProps) {
   const { t } = useTranslation()
 
   return (
-    <Button
-      bsSize="small"
-      bsStyle="primary"
-      disabled={disabled || !isValidEmail(email)}
+    <ButtonWrapper
+      size="small"
+      variant="primary"
+      disabled={(disabled && !isLoading) || !isValidEmail(email)}
+      isLoading={isLoading}
       {...props}
     >
       {t('add_new_email')}
-    </Button>
+    </ButtonWrapper>
   )
 }
 

+ 11 - 6
services/web/frontend/js/features/settings/components/emails/add-email/email-affiliated-with-institution.tsx

@@ -1,20 +1,25 @@
 import { useTranslation } from 'react-i18next'
-import { Button, ButtonProps } from 'react-bootstrap'
+import ButtonWrapper, {
+  ButtonWrapperProps,
+} from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
-function EmailAffiliatedWithInstitution({ onClick, ...props }: ButtonProps) {
+function EmailAffiliatedWithInstitution({
+  onClick,
+  ...props
+}: ButtonWrapperProps) {
   const { t } = useTranslation()
 
   return (
     <div className="mt-1">
       {t('is_email_affiliated')}
-      <Button
-        className="btn-inline-link"
+      <ButtonWrapper
+        variant="link"
         onClick={onClick}
+        bs3Props={{ bsStyle: null, className: 'btn-inline-link' }}
         {...props}
-        bsStyle={null}
       >
         {t('let_us_know')}
-      </Button>
+      </ButtonWrapper>
     </div>
   )
 }

+ 6 - 5
services/web/frontend/js/features/settings/components/emails/add-email/sso-linking-info.tsx

@@ -1,10 +1,10 @@
 import { useState } from 'react'
-import { Button } from 'react-bootstrap'
 import { Trans, useTranslation } from 'react-i18next'
 import { DomainInfo } from './input'
 import { ExposedSettings } from '../../../../../../../types/exposed-settings'
 import getMeta from '../../../../../utils/meta'
 import { useLocation } from '../../../../../shared/hooks/use-location'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
 type SSOLinkingInfoProps = {
   domainInfo: DomainInfo
@@ -54,14 +54,15 @@ function SsoLinkingInfo({ domainInfo, email }: SSOLinkingInfoProps) {
           {t('find_out_more_about_institution_login')}.
         </a>
       </p>
-      <Button
-        bsStyle="primary"
-        className="btn-sm btn-link-accounts"
+      <ButtonWrapper
+        variant="primary"
+        className="btn-link-accounts"
+        size="small"
         disabled={linkAccountsButtonDisabled}
         onClick={handleLinkAccountsButtonClick}
       >
         {t('link_accounts_and_add_email')}
-      </Button>
+      </ButtonWrapper>
     </>
   )
 }

+ 7 - 3
services/web/frontend/js/features/settings/components/emails/add-email/university-name.tsx

@@ -1,5 +1,5 @@
 import { useTranslation } from 'react-i18next'
-import { Button } from 'react-bootstrap'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
 type UniversityNameProps = {
   name: string
@@ -14,9 +14,13 @@ function UniversityName({ name, onClick }: UniversityNameProps) {
       {name}
       <span className="small">
         {' '}
-        <Button className="btn-inline-link" onClick={onClick} bsStyle={null}>
+        <ButtonWrapper
+          variant="link"
+          onClick={onClick}
+          bs3Props={{ bsStyle: null, className: 'btn-inline-link' }}
+        >
           {t('change')}
-        </Button>
+        </ButtonWrapper>
       </span>
     </p>
   )

+ 23 - 12
services/web/frontend/js/features/settings/components/emails/institution-and-role.tsx

@@ -1,7 +1,6 @@
 import { useState, useEffect, useRef } from 'react'
 import { useTranslation } from 'react-i18next'
 import { UserEmailData } from '../../../../../../types/user-email'
-import { Button } from 'react-bootstrap'
 import { isChangingAffiliation } from '../../utils/selectors'
 import { useUserEmailsContext } from '../../context/user-email-context'
 import DownshiftInput from './downshift-input'
@@ -10,6 +9,7 @@ import { getJSON, postJSON } from '../../../../infrastructure/fetch-json'
 import defaultRoles from '../../data/roles'
 import defaultDepartments from '../../data/departments'
 import { University } from '../../../../../../types/university'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
 type InstitutionAndRoleProps = {
   userEmailData: UserEmailData
@@ -107,11 +107,15 @@ function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) {
               <br />
             </>
           )}
-          <Button className="btn-inline-link" onClick={handleChangeAffiliation}>
+          <ButtonWrapper
+            onClick={handleChangeAffiliation}
+            variant="link"
+            bs3Props={{ className: 'btn-inline-link' }}
+          >
             {!affiliation.department && !affiliation.role
               ? t('add_role_and_department')
               : t('change')}
-          </Button>
+          </ButtonWrapper>
         </div>
       ) : (
         <div className="affiliation-change-container small">
@@ -135,23 +139,30 @@ function InstitutionAndRole({ userEmailData }: InstitutionAndRoleProps) {
                 setValue={setDepartment}
               />
             </div>
-            <Button
-              bsSize="small"
-              bsStyle="primary"
+            <ButtonWrapper
+              size="small"
+              variant="primary"
               type="submit"
-              disabled={!role || !department || isLoading || state.isLoading}
+              disabled={!role || !department}
+              isLoading={isLoading}
+              bs3Props={{
+                loading: isLoading
+                  ? `${t('saving')}…`
+                  : t('save_or_cancel-save'),
+              }}
             >
-              {isLoading ? <>{t('saving')}…</> : t('save_or_cancel-save')}
-            </Button>
+              {t('save_or_cancel-save')}
+            </ButtonWrapper>
             {!isLoading && (
               <>
                 <span className="mx-1">{t('save_or_cancel-or')}</span>
-                <Button
-                  className="btn-inline-link"
+                <ButtonWrapper
+                  variant="link"
                   onClick={handleCancelAffiliationChange}
+                  bs3Props={{ className: 'btn-inline-link' }}
                 >
                   {t('save_or_cancel-cancel')}
-                </Button>
+                </ButtonWrapper>
               </>
             )}
           </form>

+ 9 - 3
services/web/frontend/js/features/settings/components/emails/reconfirmation-info.tsx

@@ -146,9 +146,13 @@ function ReconfirmationInfo({ userEmailData }: ReconfirmationInfoProps) {
                       </>
                     ) : (
                       <ButtonWrapper
-                        className="btn-inline-link"
+                        variant="link"
                         disabled={state.isLoading}
                         onClick={handleRequestReconfirmation}
+                        bs3Props={{
+                          className: 'btn-inline-link',
+                          bsStyle: null,
+                        }}
                       >
                         {t('resend_confirmation_email')}
                       </ButtonWrapper>
@@ -157,7 +161,8 @@ function ReconfirmationInfo({ userEmailData }: ReconfirmationInfoProps) {
                 ) : (
                   <ButtonWrapper
                     variant="secondary"
-                    disabled={state.isLoading || isPending}
+                    disabled={isPending}
+                    isLoading={isLoading}
                     onClick={handleRequestReconfirmation}
                     bs3Props={{ bsStyle: 'info' }}
                   >
@@ -200,9 +205,10 @@ function ReconfirmationInfo({ userEmailData }: ReconfirmationInfoProps) {
                     </>
                   ) : (
                     <ButtonWrapper
-                      className="btn-inline-link"
+                      variant="link"
                       disabled={state.isLoading}
                       onClick={handleRequestReconfirmation}
+                      bs3Props={{ className: 'btn-inline-link', bsStyle: null }}
                     >
                       {t('resend_confirmation_email')}
                     </ButtonWrapper>

+ 6 - 6
services/web/frontend/js/features/settings/components/emails/resend-confirmation-email-button.tsx

@@ -1,11 +1,11 @@
 import { useEffect } from 'react'
 import { useTranslation } from 'react-i18next'
 import Icon from '../../../../shared/components/icon'
-import { Button } from 'react-bootstrap'
 import { FetchError, postJSON } from '../../../../infrastructure/fetch-json'
 import useAsync from '../../../../shared/hooks/use-async'
 import { UserEmailData } from '../../../../../../types/user-email'
 import { useUserEmailsContext } from '../../context/user-email-context'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
 type ResendConfirmationEmailButtonProps = {
   email: UserEmailData['email']
@@ -47,14 +47,14 @@ function ResendConfirmationEmailButton({
 
   return (
     <>
-      <Button
-        className="btn-inline-link"
-        disabled={state.isLoading}
+      <ButtonWrapper
+        variant="link"
+        disabled={state.isLoading || isLoading}
         onClick={handleResendConfirmationEmail}
-        bsStyle={null}
+        bs3Props={{ bsStyle: null, className: 'btn-inline-link' }}
       >
         {t('resend_confirmation_email')}
-      </Button>
+      </ButtonWrapper>
       <br />
       {isError && (
         <div className="text-danger">

+ 6 - 5
services/web/frontend/js/features/settings/components/emails/row.tsx

@@ -1,7 +1,6 @@
 import { useState } from 'react'
 import { Trans, useTranslation } from 'react-i18next'
 import { UserEmailData } from '../../../../../../types/user-email'
-import { Button } from 'react-bootstrap'
 import Email from './email'
 import InstitutionAndRole from './institution-and-role'
 import EmailCell from './cell'
@@ -16,6 +15,7 @@ import { useLocation } from '../../../../shared/hooks/use-location'
 import RowWrapper from '@/features/ui/components/bootstrap-5/wrappers/row-wrapper'
 import ColWrapper from '@/features/ui/components/bootstrap-5/wrappers/col-wrapper'
 import { bsVersion } from '@/features/utils/bootstrap-5'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
 type EmailsRowProps = {
   userEmailData: UserEmailData
@@ -160,14 +160,15 @@ function SSOAffiliationInfo({ userEmailData }: SSOAffiliationInfoProps) {
             })}
           >
             <EmailCell>
-              <Button
-                bsStyle="primary"
-                className="btn-sm btn-link-accounts"
+              <ButtonWrapper
+                variant="primary"
+                className="btn-link-accounts"
                 disabled={linkAccountsButtonDisabled}
                 onClick={handleLinkAccountsButtonClick}
+                size="small"
               >
                 {t('link_accounts')}
-              </Button>
+              </ButtonWrapper>
             </EmailCell>
           </ColWrapper>
         </RowWrapper>

+ 11 - 2
services/web/frontend/js/features/settings/components/leave-section.tsx

@@ -2,6 +2,8 @@ import { useState, useCallback } from 'react'
 import { useTranslation } from 'react-i18next'
 import LeaveModal from './leave/modal'
 import getMeta from '../../../utils/meta'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 function LeaveSection() {
   const { t } = useTranslation()
@@ -28,9 +30,16 @@ function LeaveSection() {
   return (
     <>
       {t('need_to_leave')}{' '}
-      <button className="btn btn-inline-link btn-danger" onClick={handleOpen}>
+      <ButtonWrapper
+        className={bsVersion({
+          bs3: 'btn btn-inline-link btn-danger',
+          bs5: 'btn-link',
+        })}
+        variant="danger"
+        onClick={handleOpen}
+      >
         {t('delete_your_account')}
-      </button>
+      </ButtonWrapper>
       <LeaveModal isOpen={isModalOpen} handleClose={handleClose} />
     </>
   )

+ 9 - 9
services/web/frontend/js/features/settings/components/leave/modal-content.tsx

@@ -1,9 +1,10 @@
 import { useState, Dispatch, SetStateAction } from 'react'
-import { Modal, Button } from 'react-bootstrap'
+import { Modal } from 'react-bootstrap'
 import { useTranslation, Trans } from 'react-i18next'
 import getMeta from '../../../../utils/meta'
 import LeaveModalForm, { LeaveModalFormProps } from './modal-form'
 import { ExposedSettings } from '../../../../../../types/exposed-settings'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
 type LeaveModalContentProps = {
   handleHide: () => void
@@ -68,24 +69,23 @@ function LeaveModalContent({
       </Modal.Body>
 
       <Modal.Footer>
-        <Button
-          type="button"
+        <ButtonWrapper
           disabled={inFlight}
           onClick={handleHide}
-          bsStyle={null}
-          className="btn-secondary"
+          variant="secondary"
+          bs3Props={{ bsStyle: null, className: 'btn-secondary' }}
         >
           {t('cancel')}
-        </Button>
+        </ButtonWrapper>
 
-        <Button
+        <ButtonWrapper
           form="leave-form"
           type="submit"
-          bsStyle="danger"
+          variant="danger"
           disabled={inFlight || !isFormValid}
         >
           {inFlight ? <>{t('deleting')}…</> : t('delete')}
-        </Button>
+        </ButtonWrapper>
       </Modal.Footer>
     </>
   )

+ 16 - 13
services/web/frontend/js/features/settings/components/linking/enable-widget.tsx

@@ -1,8 +1,8 @@
 import { ReactNode } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Button } from 'react-bootstrap'
 import { sendMB } from '@/infrastructure/event-tracking'
 import BadgeWrapper from '@/features/ui/components/bootstrap-5/wrappers/badge-wrapper'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
 function trackUpgradeClick() {
   sendMB('settings-upgrade-click')
@@ -92,36 +92,39 @@ function ActionButton({
   const { t } = useTranslation()
   if (!hasFeature) {
     return (
-      <Button
-        bsStyle={null}
-        className="btn-primary"
+      <ButtonWrapper
+        variant="primary"
         href="/user/subscription/plans"
         onClick={trackUpgradeClick}
+        bs3Props={{ bsStyle: null, className: 'btn-primary' }}
       >
         <span className="text-capitalize">{t('upgrade')}</span>
-      </Button>
+      </ButtonWrapper>
     )
   } else if (linked) {
     return (
-      <Button
-        className="btn-danger-ghost"
+      <ButtonWrapper
+        variant="danger-ghost"
         onClick={handleUnlinkClick}
-        bsStyle={null}
         disabled={disabled}
+        bs3Props={{ bsStyle: null, className: 'btn-danger-ghost' }}
       >
         {t('turn_off')}
-      </Button>
+      </ButtonWrapper>
     )
   } else {
     return (
-      <Button
+      <ButtonWrapper
+        variant="secondary"
         disabled={disabled}
-        bsStyle={null}
         onClick={handleLinkClick}
-        className="btn btn-secondary-info btn-secondary text-capitalize"
+        bs3Props={{
+          bsStyle: null,
+          className: 'btn btn-secondary-info btn-secondary',
+        }}
       >
         {t('turn_on')}
-      </Button>
+      </ButtonWrapper>
     )
   }
 }

+ 40 - 23
services/web/frontend/js/features/settings/components/linking/integration-widget.tsx

@@ -1,10 +1,12 @@
 import { useCallback, useState, ReactNode } from 'react'
 import { useTranslation } from 'react-i18next'
 import AccessibleModal from '../../../../shared/components/accessible-modal'
+import { Modal } from 'react-bootstrap'
 import BadgeWrapper from '@/features/ui/components/bootstrap-5/wrappers/badge-wrapper'
-import { Button, Modal } from 'react-bootstrap'
 import getMeta from '../../../../utils/meta'
 import { sendMB } from '../../../../infrastructure/event-tracking'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 function trackUpgradeClick() {
   sendMB('settings-upgrade-click')
@@ -107,43 +109,52 @@ function ActionButton({
   const { t } = useTranslation()
   if (!hasFeature) {
     return (
-      <Button
-        bsStyle={null}
-        className="btn-primary"
+      <ButtonWrapper
+        variant="primary"
         href="/user/subscription/plans"
         onClick={trackUpgradeClick}
+        bs3Props={{ bsStyle: null, className: 'btn-primary' }}
       >
         <span className="text-capitalize">{t('upgrade')}</span>
-      </Button>
+      </ButtonWrapper>
     )
   } else if (linked) {
     return (
-      <Button
-        className="btn-danger-ghost"
+      <ButtonWrapper
+        variant="danger-ghost"
         onClick={handleUnlinkClick}
-        bsStyle={null}
         disabled={disabled}
+        bs3Props={{ bsStyle: null, className: 'btn-danger-ghost' }}
       >
         {t('unlink')}
-      </Button>
+      </ButtonWrapper>
     )
   } else {
     return (
       <>
         {disabled ? (
-          <button
+          <ButtonWrapper
             disabled
-            className="btn btn-secondary-info btn-secondary text-capitalize"
+            variant="secondary"
+            className={bsVersion({
+              bs3: 'btn btn-secondary-info btn-secondary text-capitalize',
+              bs5: 'text-capitalize',
+            })}
           >
             {t('link')}
-          </button>
+          </ButtonWrapper>
         ) : (
-          <a
-            className="btn btn-secondary-info btn-secondary text-capitalize"
+          <ButtonWrapper
+            variant="secondary"
             href={linkPath}
+            className={bsVersion({
+              bs3: 'btn btn-secondary-info btn-secondary text-capitalize',
+              bs5: 'text-capitalize',
+            })}
+            bs3Props={{ bsStyle: null }}
           >
             {t('link')}
-          </a>
+          </ButtonWrapper>
         )}
       </>
     )
@@ -167,9 +178,7 @@ function UnlinkConfirmationModal({
 }: UnlinkConfirmModalProps) {
   const { t } = useTranslation()
 
-  const handleCancel = (
-    event: React.MouseEvent<HTMLButtonElement & Button>
-  ) => {
+  const handleCancel = (event: React.MouseEvent<HTMLButtonElement>) => {
     event.preventDefault()
     handleHide()
   }
@@ -186,15 +195,23 @@ function UnlinkConfirmationModal({
       <Modal.Footer>
         <form action={unlinkPath} method="POST" className="form-inline">
           <input type="hidden" name="_csrf" value={getMeta('ol-csrfToken')} />
-          <Button
-            className="btn-secondary-info btn-secondary"
+          <ButtonWrapper
+            variant="secondary"
             onClick={handleCancel}
+            bs3Props={{
+              bsStyle: null,
+              className: 'btn-secondary-info btn-secondary',
+            }}
           >
             {t('cancel')}
-          </Button>
-          <Button type="submit" className="btn-danger-ghost" bsStyle={null}>
+          </ButtonWrapper>
+          <ButtonWrapper
+            type="submit"
+            variant="danger-ghost"
+            bs3Props={{ bsStyle: null, className: 'btn-danger-ghost' }}
+          >
             {t('unlink')}
-          </Button>
+          </ButtonWrapper>
         </form>
       </Modal.Footer>
     </AccessibleModal>

+ 32 - 18
services/web/frontend/js/features/settings/components/linking/sso-widget.tsx

@@ -1,12 +1,14 @@
 import { useCallback, useState } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Button, Modal } from 'react-bootstrap'
+import { Modal } from 'react-bootstrap'
 import { FetchError } from '../../../../infrastructure/fetch-json'
 import AccessibleModal from '../../../../shared/components/accessible-modal'
 import IEEELogo from '../../../../shared/svgs/ieee-logo'
 import GoogleLogo from '../../../../shared/svgs/google-logo'
 import OrcidLogo from '../../../../shared/svgs/orcid-logo'
 import LinkingStatus from './status'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
+import { bsVersion } from '@/features/utils/bootstrap-5'
 
 const providerLogos: { readonly [p: string]: JSX.Element } = {
   collabratec: <IEEELogo />,
@@ -112,28 +114,37 @@ function ActionButton({
   const { t } = useTranslation()
   if (unlinkRequestInflight) {
     return (
-      <Button className="btn-danger-ghost" bsStyle={null} disabled>
+      <ButtonWrapper
+        variant="danger-ghost"
+        disabled
+        bs3Props={{ bsStyle: null, className: 'btn-danger-ghost' }}
+      >
         {t('unlinking')}
-      </Button>
+      </ButtonWrapper>
     )
   } else if (accountIsLinked) {
     return (
-      <Button
-        className="btn-danger-ghost"
-        bsStyle={null}
+      <ButtonWrapper
+        variant="danger-ghost"
         onClick={onUnlinkClick}
+        bs3Props={{ bsStyle: null, className: 'btn-danger-ghost' }}
       >
         {t('unlink')}
-      </Button>
+      </ButtonWrapper>
     )
   } else {
     return (
-      <a
+      <ButtonWrapper
+        variant="secondary"
         href={linkPath}
-        className="btn btn-secondary-info btn-secondary text-capitalize"
+        bs3Props={{ bsStyle: null }}
+        className={bsVersion({
+          bs3: 'btn btn-secondary-info btn-secondary text-capitalize',
+          bs5: 'text-capitalize',
+        })}
       >
         {t('link')}
-      </a>
+      </ButtonWrapper>
     )
   }
 }
@@ -166,20 +177,23 @@ function UnlinkConfirmModal({
       </Modal.Body>
 
       <Modal.Footer>
-        <Button
-          bsStyle={null}
-          className="btn-secondary-info btn-secondary"
+        <ButtonWrapper
+          variant="secondary"
           onClick={handleHide}
+          bs3Props={{
+            bsStyle: null,
+            className: 'btn-secondary-info btn-secondary',
+          }}
         >
           {t('cancel')}
-        </Button>
-        <Button
-          className="btn-danger-ghost"
-          bsStyle={null}
+        </ButtonWrapper>
+        <ButtonWrapper
+          variant="danger-ghost"
           onClick={handleConfirmation}
+          bs3Props={{ bsStyle: null, className: 'btn-danger-ghost' }}
         >
           {t('unlink')}
-        </Button>
+        </ButtonWrapper>
       </Modal.Footer>
     </AccessibleModal>
   )

+ 2 - 1
services/web/frontend/js/features/settings/components/password-section.tsx

@@ -201,7 +201,8 @@ function PasswordForm() {
         form="password-change-form"
         type="submit"
         variant="primary"
-        disabled={isLoading || !isFormValid}
+        disabled={!isFormValid}
+        isLoading={isLoading}
         bs3Props={{
           loading: isLoading ? `${t('saving')}…` : t('change'),
         }}

+ 5 - 3
services/web/frontend/js/features/settings/components/security-section.tsx

@@ -2,6 +2,7 @@ import MaterialIcon from '@/shared/components/material-icon'
 import { Trans, useTranslation } from 'react-i18next'
 import { GroupSSOLinkingStatus } from '../../../../../types/subscription/sso'
 import getMeta from '../../../utils/meta'
+import ButtonWrapper from '@/features/ui/components/bootstrap-5/wrappers/button-wrapper'
 
 function SecuritySection() {
   const { t } = useTranslation()
@@ -84,12 +85,13 @@ function SecuritySection() {
                 </div>
                 {linked ? null : (
                   <div className="button-column">
-                    <a
-                      className="btn btn-primary"
+                    <ButtonWrapper
+                      variant="primary"
+                      bs3Props={{ className: 'btn btn-primary', bsStyle: null }}
                       href={`/subscription/${groupId}/sso_enrollment`}
                     >
                       {t('set_up_sso')}
-                    </a>
+                    </ButtonWrapper>
                   </div>
                 )}
               </div>

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

@@ -7,6 +7,7 @@ import Button from '../button'
 export type ButtonWrapperProps = ButtonProps & {
   bs3Props?: {
     bsStyle?: string | null
+    className?: string
     loading?: React.ReactNode
   }
 }
@@ -26,11 +27,12 @@ export default function ButtonWrapper(props: ButtonWrapperProps) {
   const { bs3Props, ...rest } = props
 
   const bs3ButtonProps: BS3ButtonProps = {
-    bsStyle: rest.variant,
+    bsStyle: rest.variant === 'secondary' ? 'default' : rest.variant,
     bsSize: mapBsButtonSizes(rest.size),
     className: rest.className,
     disabled: rest.isLoading || rest.disabled,
     form: rest.form,
+    href: rest.href,
     onClick: rest.onClick,
     type: rest.type,
     ...bs3Props,

+ 1 - 0
services/web/frontend/js/features/ui/components/types/button-props.ts

@@ -19,4 +19,5 @@ export type ButtonProps = {
     | 'danger'
     | 'danger-ghost'
     | 'premium'
+    | 'link'
 }

+ 2 - 2
services/web/frontend/stylesheets/bootstrap-5/base/links.scss

@@ -2,12 +2,12 @@
 
 // Use CSS variables for link colors to make it easy to override in marketing page
 :root {
-  --link-color: var(--link-ui-visited);
+  --link-color: var(--link-ui);
   --link-hover-color: var(--link-ui-hover);
   --link-visited-color: var(--link-ui-visited);
 }
 
-a {
+a:not([role='button']) {
   color: var(--link-color);
 
   &:hover {

+ 34 - 0
services/web/frontend/stylesheets/bootstrap-5/components/button.scss

@@ -87,10 +87,44 @@
   }
 }
 
+// Link buttons
+// -------------------------
+
+// Make a button look and behave like a link
+.btn-link {
+  color: var(--link-ui);
+  font-weight: normal;
+  cursor: pointer;
+  border-radius: 0;
+  text-decoration: underline;
+  padding: 0;
+  font-size: inherit;
+  vertical-align: inherit;
+
+  &,
+  &:active,
+  &[disabled],
+  fieldset[disabled] & {
+    background-color: transparent;
+    @include box-shadow(none);
+  }
+  &:hover,
+  &:focus {
+    color: var(--link-ui-hover);
+    text-decoration: none;
+    background-color: transparent;
+  }
+
+  &.btn-danger {
+    color: var(--content-danger);
+  }
+}
+
 .button-loading {
   align-items: center;
   display: inline-grid;
   grid-template-areas: 'container'; // Define a single grid area
+  pointer-events: none;
 }
 
 .button-loading > * {