diff --git a/services/web/frontend/js/features/user-settings/components/sso-linking-widget.tsx b/services/web/frontend/js/features/user-settings/components/sso-linking-widget.tsx new file mode 100644 index 0000000000..634bda7cca --- /dev/null +++ b/services/web/frontend/js/features/user-settings/components/sso-linking-widget.tsx @@ -0,0 +1,151 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Modal } from 'react-bootstrap' +import AccessibleModal from '../../../shared/components/accessible-modal' + +type SSOLinkingWidgetProps = { + logoSrc: string + title: string + description: string + linked?: boolean + linkPath: string + onUnlink: () => Promise + unlinkConfirmationTitle: string + unlinkConfirmationText: string +} + +export function SSOLinkingWidget({ + logoSrc, + title, + description, + linked, + linkPath, + onUnlink, + unlinkConfirmationTitle, + unlinkConfirmationText, +}: SSOLinkingWidgetProps) { + const [showModal, setShowModal] = useState(false) + const [unlinkRequestInflight, setUnlinkRequestInflight] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + + const handleUnlinkClick = useCallback(() => { + setShowModal(true) + }, []) + + const handleUnlinkConfirmationClick = useCallback(() => { + setShowModal(false) + setUnlinkRequestInflight(true) + onUnlink() + .catch((error: Error) => { + setErrorMessage(error.message) + }) + .finally(() => { + setUnlinkRequestInflight(false) + }) + }, [onUnlink]) + + const handleModalHide = useCallback(() => { + setShowModal(false) + }, []) + + return ( +
+
+ {title} +
+
+

{title}

+

{description}

+ {errorMessage &&
{errorMessage}
} +
+
+ +
+ +
+ ) +} +type ActionButtonProps = { + unlinkRequestInflight: boolean + accountIsLinked?: boolean + linkPath: string + onUnlinkClick: () => void +} + +function ActionButton({ + unlinkRequestInflight, + accountIsLinked, + linkPath, + onUnlinkClick, +}: ActionButtonProps) { + const { t } = useTranslation() + if (unlinkRequestInflight) { + return ( + + ) + } else if (accountIsLinked) { + return ( + + ) + } else { + return ( + + {t('link')} + + ) + } +} + +type UnlinkConfirmModalProps = { + show: boolean + title: string + content: string + handleConfirmation: () => void + handleHide: () => void +} + +function UnlinkConfirmModal({ + show, + title, + content, + handleConfirmation, + handleHide, +}: UnlinkConfirmModalProps) { + const { t } = useTranslation() + + return ( + + + {title} + + + +

{content}

+
+ + + + + +
+ ) +} diff --git a/services/web/test/frontend/features/user-settings/components/sso-linking-widget.test.tsx b/services/web/test/frontend/features/user-settings/components/sso-linking-widget.test.tsx new file mode 100644 index 0000000000..a89801140c --- /dev/null +++ b/services/web/test/frontend/features/user-settings/components/sso-linking-widget.test.tsx @@ -0,0 +1,125 @@ +import { expect } from 'chai' +import sinon from 'sinon' +import { screen, fireEvent, render, waitFor } from '@testing-library/react' +import { SSOLinkingWidget } from '../../../../../frontend/js/features/user-settings/components/sso-linking-widget' + +describe('', function () { + const defaultProps = { + logoSrc: 'logo.png', + title: 'integration', + description: 'integration description', + linkPath: 'integration/link', + unlinkConfirmationTitle: 'confirm unlink', + unlinkConfirmationText: 'you will be unlinked', + onUnlink: () => Promise.resolve(), + } + + it('should render', function () { + render() + screen.getByText('integration') + screen.getByText('integration description') + }) + + describe('when unlinked', function () { + it('should render a link to `linkPath`', function () { + render() + expect( + screen.getByRole('link', { name: 'link' }).getAttribute('href') + ).to.equal('integration/link') + }) + }) + + describe('when linked', function () { + let unlinkFunction + + beforeEach(function () { + unlinkFunction = sinon.stub() + render( + + ) + }) + + it('should display an `unlink` button', function () { + screen.getByRole('button', { name: 'Unlink' }) + }) + + it('should open a modal to confirm integration unlinking', function () { + fireEvent.click(screen.getByRole('button', { name: 'Unlink' })) + screen.getByText('confirm unlink') + screen.getByText('you will be unlinked') + }) + + it('should cancel unlinking when clicking cancel in the confirmation modal', async function () { + fireEvent.click(screen.getByRole('button', { name: 'Unlink' })) + screen.getByText('confirm unlink') + const cancelBtn = screen.getByRole('button', { + name: 'Cancel', + hidden: false, + }) + fireEvent.click(cancelBtn) + await waitFor(() => + screen.getByRole('button', { name: 'Cancel', hidden: true }) + ) + expect(unlinkFunction).not.to.have.been.called + }) + }) + + describe('unlinking an account', function () { + let confirmBtn, unlinkFunction + + beforeEach(function () { + unlinkFunction = sinon.stub() + render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Unlink' })) + confirmBtn = screen.getByRole('button', { + name: 'Unlink', + hidden: false, + }) + }) + + it('should make an `unlink` request', function () { + unlinkFunction.resolves() + fireEvent.click(confirmBtn) + expect(unlinkFunction).to.have.been.called + }) + + it('should display feedback while the request is inflight', async function () { + unlinkFunction.returns( + new Promise(resolve => { + setTimeout(resolve, 500) + }) + ) + fireEvent.click(confirmBtn) + await waitFor(() => + expect(screen.getByRole('button', { name: 'Unlinking' })) + ) + }) + }) + + describe('when unlinking fails', function () { + beforeEach(function () { + const unlinkFunction = sinon.stub().rejects(new Error('unlinking failed')) + render( + + ) + fireEvent.click(screen.getByRole('button', { name: 'Unlink' })) + const confirmBtn = screen.getByRole('button', { + name: 'Unlink', + hidden: false, + }) + fireEvent.click(confirmBtn) + }) + + it('should display an error message ', async function () { + await waitFor(() => screen.getByText('unlinking failed')) + }) + + it('should display the unlink button ', async function () { + await waitFor(() => + expect(screen.getByRole('button', { name: 'Unlink' })) + ) + }) + }) +})