From 6dae616a516e9bf69cd89b09364eeb8e68fce0f4 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Fri, 15 Sep 2023 20:02:17 +0000 Subject: [PATCH 1/2] feat: New SSO workflow skeleton fix: Make service provider metadata button primary color feat: Add non-SAP fields to SSO Configure page feat: Show different SSO metadata inputs based on radio selection test: Add test for SSO metadata input hiding feat: Add back buttons to sso config workflow chore: rename function --- src/components/forms/FormContextWrapper.tsx | 3 +- src/components/forms/FormWorkflow.tsx | 25 +++- src/components/forms/ValidatedFormControl.tsx | 3 + src/components/forms/ValidatedFormRadio.tsx | 3 +- .../settings/SettingsLMSTab/LMSConfigPage.jsx | 1 + .../LMSConfigs/SAP/SAPConfigEnablePage.tsx | 1 + .../tests/BlackboardConfig.test.tsx | 1 + .../tests/CanvasConfig.test.tsx | 1 + .../tests/DegreedConfig.test.tsx | 1 + .../settings/SettingsSSOTab/NewSSOStepper.jsx | 30 +++- .../SettingsSSOTab/SSOFormWorkflowConfig.tsx | 59 ++++++++ .../steps/NewSSOConfigAuthorizeStep.tsx | 38 +++++ .../steps/NewSSOConfigConfigureStep.tsx | 130 ++++++++++++++++++ .../steps/NewSSOConfigConfirmStep.tsx | 53 +++++++ .../steps/NewSSOConfigConnectStep.tsx | 88 ++++++++++++ .../tests/NewSSOConfigForm.test.jsx | 118 +++++++++++++--- 16 files changed, 533 insertions(+), 22 deletions(-) create mode 100644 src/components/settings/SettingsSSOTab/SSOFormWorkflowConfig.tsx create mode 100644 src/components/settings/SettingsSSOTab/steps/NewSSOConfigAuthorizeStep.tsx create mode 100644 src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfigureStep.tsx create mode 100644 src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfirmStep.tsx create mode 100644 src/components/settings/SettingsSSOTab/steps/NewSSOConfigConnectStep.tsx diff --git a/src/components/forms/FormContextWrapper.tsx b/src/components/forms/FormContextWrapper.tsx index cec05e1483..a6d745f896 100644 --- a/src/components/forms/FormContextWrapper.tsx +++ b/src/components/forms/FormContextWrapper.tsx @@ -8,6 +8,7 @@ import { type FormWrapperProps = FormWorkflowProps & { formData: FormConfigData }; const FormContextWrapper = ({ + workflowTitle, formWorkflowConfig, onClickOut, formData, @@ -32,7 +33,7 @@ const FormContextWrapper = ({ > diff --git a/src/components/forms/FormWorkflow.tsx b/src/components/forms/FormWorkflow.tsx index f6c2a9d6da..d3bb072534 100644 --- a/src/components/forms/FormWorkflow.tsx +++ b/src/components/forms/FormWorkflow.tsx @@ -53,6 +53,8 @@ export type FormWorkflowStep = { errHandler: FormWorkflowErrorHandler ) => Promise; nextButtonConfig: (FormData: FormData) => FormWorkflowButtonConfig; + showBackButton?: boolean; + showCancelButton?: boolean; }; export type FormWorkflowConfig = { @@ -61,14 +63,16 @@ export type FormWorkflowConfig = { }; export type FormWorkflowProps = { + workflowTitle: string; formWorkflowConfig: FormWorkflowConfig; - onClickOut: (edited: boolean, msg?: string) => null; + onClickOut: (() => void) | ((edited?: boolean, msg?: string) => null); dispatch: Dispatch; isStepperOpen: boolean; }; // Modal container for multi-step forms const FormWorkflow = ({ + workflowTitle, formWorkflowConfig, onClickOut, isStepperOpen, @@ -150,6 +154,15 @@ const FormWorkflow = ({ } }; + const onBack = () => { + if (step?.index !== undefined) { + const previousStep: number = step.index - 1; + if (previousStep >= 0) { + dispatch(setStepAction({ step: formWorkflowConfig.steps[previousStep] })); + } + } + }; + const stepBody = (currentStep: FormWorkflowStep) => { if (currentStep) { const FormComponent: DynamicComponent = currentStep?.formComponent; @@ -175,6 +188,10 @@ const FormWorkflow = ({ } }, [formFields]); + // Show back button only if showBackButton === true + const showBackButton = (step?.index !== undefined) && (step.index > 0) && step.showBackButton; + // Show cancel button by default + const showCancelButton = step?.showCancelButton === undefined || step?.showCancelButton; return ( <> ({ await step?.saveChanges(formFields as FormConfigData, setFormError); onClickOut(true, SUBMIT_TOAST_MESSAGE); } + onClickOut(false, 'No changes saved'); }} /> {formWorkflowConfig.steps && ( ({ Help Center: Integrations - + {showCancelButton && } + {showBackButton && } {nextButtonConfig && ( + + +

+ 2. Launch a new window and upload the XML file to the list of + authorized SAML Service Providers on your Identity Provider's portal or website. +

+
+

Return to this window and check the box once complete

+ + + I have authorized edX as a Service Provider + + +); + +export default SSOConfigAuthorizeStep; diff --git a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfigureStep.tsx b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfigureStep.tsx new file mode 100644 index 0000000000..50cf5b5ba2 --- /dev/null +++ b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfigureStep.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { + Form, Container, +} from '@edx/paragon'; + +import ValidatedFormControl from '../../../forms/ValidatedFormControl'; + +const SSOConfigConfigureStep = () => { + const renderBaseFields = () => ( + <> +

Enter user attributes

+

+ Please enter the SAML user attributes from your Identity Provider. + All attributes are space and case sensitive. +

+ + + + + + + + + + + + + + + + + ); + const renderSAPFields = () => ( + <> +

Enable learner account auto-registration

+ + + + + + + + + + + + + + + + + ); + + return ( + + + +
+

Enter integration details

+

Set display name

+ + + + + {/* TODO: Render SAP fields selectively once logic is in place */} + {renderBaseFields()} + {renderSAPFields()} +
+
+ ); +}; + +export default SSOConfigConfigureStep; diff --git a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfirmStep.tsx b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfirmStep.tsx new file mode 100644 index 0000000000..f43bdeeb71 --- /dev/null +++ b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConfirmStep.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { + Alert, Hyperlink, OverlayTrigger, Popover, +} from '@edx/paragon'; +import { Info } from '@edx/paragon/icons'; + +const IncognitoPopover = () => ( + + + Steps to open a new window in incognito mode (also known as private mode) + may vary based on the browser you are using. + Review your browser's help documentation as needed. + + + )} + > + incognito window + +); + +const SSOConfigConfirmStep = () => ( + <> +

Wait for SSO configuration confirmation

+ +

Action required from email

+ Great news! You have completed the configuration steps, edX is actively configuring your SSO connection. + You will receive an email within about five minutes when the configuration is complete. + The email will include instructions for testing. +
+
+

What to expect:

+
    +
  • SSO configuration confirmation email.
  • +
      +
    • Testing instructions involve copying and pasting a custom URL into an
    • +
    • A link back to the SSO Settings page
    • +
    +
+
+

+ Select the "Finish" button below or close this form via the + "X" in the upper right corner while you wait for your + configuration email. Your SSO testing status will display on the following SSO settings screen. +

+ +); + +export default SSOConfigConfirmStep; diff --git a/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConnectStep.tsx b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConnectStep.tsx new file mode 100644 index 0000000000..4b66625d11 --- /dev/null +++ b/src/components/settings/SettingsSSOTab/steps/NewSSOConfigConnectStep.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { Container, Dropzone, Form } from '@edx/paragon'; + +import ValidatedFormRadio from '../../../forms/ValidatedFormRadio'; +import ValidatedFormControl from '../../../forms/ValidatedFormControl'; +import { FormContext, useFormContext } from '../../../forms/FormContext'; + +const SSOConfigConnectStep = () => { + const fiveGbInBytes = 5368709120; + const ssoIdpOptions = [ + ['Microsoft Azure Active Directory (Azure AD)', 'azure_ad'], + ['Google Workspace', 'google_workspace'], + ['Okta', 'okta'], + ['OneLogin', 'one_login'], + ['SAP SuccessFactors', 'sap_success_factors'], + ['Other', 'other'], + ]; + const idpConnectOptions = [ + ['Enter identity Provider Metadata URL', 'idp_metadata_url'], + ['Upload Identity Provider Metadata XML file', 'idp_metadata_xml'], + ]; + + const { + formFields, + }: FormContext = useFormContext(); + const showUrlEntry = formFields?.idpConnectOption === 'idp_metadata_url'; + const showXmlUpload = formFields?.idpConnectOption === 'idp_metadata_xml'; + + // TODO: Store uploaded XML data + const onUploadXml = () => null; + + return ( + + +
+ +

Let's get started

+ What is your organization's SSO Identity Provider? + + + + +

Connect edX to your Identity Provider

+ Select a method to connect edX to your Identity Provider + + + + + {showUrlEntry && ( + + + + )} + + {showXmlUpload + && ( + + )} + +
+ ); +}; + +export default SSOConfigConnectStep; diff --git a/src/components/settings/SettingsSSOTab/tests/NewSSOConfigForm.test.jsx b/src/components/settings/SettingsSSOTab/tests/NewSSOConfigForm.test.jsx index 03372c4fd5..01ffa3a0a3 100644 --- a/src/components/settings/SettingsSSOTab/tests/NewSSOConfigForm.test.jsx +++ b/src/components/settings/SettingsSSOTab/tests/NewSSOConfigForm.test.jsx @@ -1,6 +1,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import { Provider } from 'react-redux'; import NewSSOConfigForm from '../NewSSOConfigForm'; @@ -69,6 +70,20 @@ const contextValue = { setRefreshBool: jest.fn(), }; +// TODO: Put this in helper library? +const getButtonElement = (buttonText) => screen.getByRole('button', { name: buttonText }); + +const setupNewSSOStepper = () => { + features.AUTH0_SELF_SERVICE_INTEGRATION = true; + return render( + + + + + , + ); +}; + describe('SAML Config Tab', () => { afterEach(() => { features.AUTH0_SELF_SERVICE_INTEGRATION = false; @@ -309,23 +324,94 @@ describe('SAML Config Tab', () => { expect(screen.getByText('Next')).not.toBeDisabled(); }, []); }); - test('show new SSO stepper placeholder when feature flag enabled', async () => { - // Setup - features.AUTH0_SELF_SERVICE_INTEGRATION = true; - contextValue.ssoState.currentStep = 'idp'; - render( - - - , - ); + test('navigate through new sso workflow skeleton', async () => { + setupNewSSOStepper(); + // Connect Step await waitFor(() => { - expect( - screen.queryByText( - 'Connect to a SAML identity provider for single sign-on' - + ' to allow quick access to your organization\'s learning catalog.', - ), - ).toBeInTheDocument(); - expect(screen.queryByText('Next')).not.toBeInTheDocument(); + expect(getButtonElement('Next')).toBeInTheDocument(); + }, []); + expect(screen.queryByText('New SSO integration')).toBeInTheDocument(); + expect(screen.queryByText('Connect')).toBeInTheDocument(); + expect(screen.queryByText('Let\'s get started')).toBeInTheDocument(); + userEvent.click(getButtonElement('Next')); + + // Configure Step + await waitFor(() => { + expect(getButtonElement('Configure')).toBeInTheDocument(); + }, []); + expect(screen.queryByText('Enter integration details')).toBeInTheDocument(); + userEvent.click(getButtonElement('Configure')); + + // Authorize Step + await waitFor(() => { + expect(getButtonElement('Next')).toBeInTheDocument(); + }, []); + expect(screen.queryByText('Authorize edX as a Service Provider')).toBeInTheDocument(); + userEvent.click(getButtonElement('Next')); + + // Confirm and Test Step + await waitFor(() => { + expect(getButtonElement('Finish')).toBeInTheDocument(); + }, []); + expect(screen.queryByText('Wait for SSO configuration confirmation')).toBeInTheDocument(); + }); + test('show correct metadata entry based on selection', async () => { + setupNewSSOStepper(); + await waitFor(() => { + expect(getButtonElement('Next')).toBeInTheDocument(); + }, []); + + const enterUrlText = 'Find the URL in your Identity Provider portal or website.'; + const uploadXmlText = 'Drag and drop your file here or click to upload.'; + + // Verify metadata selectors are hidden initially + expect(screen.queryByText(enterUrlText)).not.toBeInTheDocument(); + expect(screen.queryByText(uploadXmlText)).not.toBeInTheDocument(); + + // Verify metadata selectors appear with their respective selections + userEvent.click(screen.getByText('Enter identity Provider Metadata URL')); + await waitFor(() => { + expect(screen.queryByText(enterUrlText)).toBeInTheDocument(); + }, []); + expect(screen.queryByText(uploadXmlText)).not.toBeInTheDocument(); + + userEvent.click(screen.getByText('Upload Identity Provider Metadata XML file')); + await waitFor(() => { + expect(screen.queryByText(uploadXmlText)).toBeInTheDocument(); + }, []); + expect(screen.queryByText(enterUrlText)).not.toBeInTheDocument(); + }); + test('back button shown on pages after first page', async () => { + const getBackButton = () => getButtonElement('Back'); + setupNewSSOStepper(); + // Connect Step + await waitFor(() => { + expect(getButtonElement('Next')).toBeInTheDocument(); + }, []); + expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument(); + userEvent.click(getButtonElement('Next')); + + // Configure Step + await waitFor(() => { + expect(getButtonElement('Configure')).toBeInTheDocument(); + }, []); + expect(getBackButton()).toBeInTheDocument(); + userEvent.click(getButtonElement('Configure')); + + // Authorize Step + await waitFor(() => { + expect(getButtonElement('Next')).toBeInTheDocument(); + }, []); + expect(getBackButton()).toBeInTheDocument(); + userEvent.click(getButtonElement('Next')); + + // Back from Confirm and Test Step + await waitFor(() => { + expect(getButtonElement('Finish')).toBeInTheDocument(); + }, []); + userEvent.click(getBackButton()); + await waitFor(() => { + expect(screen.queryByText('Authorize edX as a Service Provider')).toBeInTheDocument(); }, []); }); test('idp step fetches and displays existing idp data fields', async () => { From 09e4c7111aa6d70e0086d4ef23aea08d96b0d469 Mon Sep 17 00:00:00 2001 From: Marlon Keating Date: Thu, 21 Sep 2023 19:53:46 +0000 Subject: [PATCH 2/2] test: New sso flow cancel button test --- .../tests/NewSSOConfigForm.test.jsx | 30 +++++++++++++++++-- src/components/test/testUtils.jsx | 4 ++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/components/settings/SettingsSSOTab/tests/NewSSOConfigForm.test.jsx b/src/components/settings/SettingsSSOTab/tests/NewSSOConfigForm.test.jsx index 01ffa3a0a3..4cc7cecc93 100644 --- a/src/components/settings/SettingsSSOTab/tests/NewSSOConfigForm.test.jsx +++ b/src/components/settings/SettingsSSOTab/tests/NewSSOConfigForm.test.jsx @@ -14,6 +14,7 @@ import { INVALID_ODATA_API_TIMEOUT_INTERVAL, INVALID_SAPSF_OAUTH_ROOT_URL, INVALID_API_ROOT_URL, } from '../../data/constants'; import { features } from '../../../../config'; +import { getButtonElement } from '../../../test/testUtils'; jest.mock('../data/actions'); jest.mock('../../utils'); @@ -70,9 +71,6 @@ const contextValue = { setRefreshBool: jest.fn(), }; -// TODO: Put this in helper library? -const getButtonElement = (buttonText) => screen.getByRole('button', { name: buttonText }); - const setupNewSSOStepper = () => { features.AUTH0_SELF_SERVICE_INTEGRATION = true; return render( @@ -414,6 +412,32 @@ describe('SAML Config Tab', () => { expect(screen.queryByText('Authorize edX as a Service Provider')).toBeInTheDocument(); }, []); }); + test('cancel out of new SSO workflow', async () => { + setupNewSSOStepper(); + // Connect Step + await waitFor(() => { + expect(getButtonElement('Cancel')).toBeInTheDocument(); + }, []); + userEvent.click(getButtonElement('Cancel')); + await waitFor(() => { + expect(getButtonElement('Cancel')).toBeInTheDocument(); + }, []); + + await waitFor(() => { + const exitButton = getButtonElement('Exit without saving'); + expect(exitButton).toBeInTheDocument(); + userEvent.click(exitButton); + }, []); + + await waitFor(() => { + expect( + screen.queryByText( + 'Connect to a SAML identity provider for single sign-on' + + ' to allow quick access to your organization\'s learning catalog.', + ), + ).toBeInTheDocument(); + }, []); + }); test('idp step fetches and displays existing idp data fields', async () => { // Setup const mockGetProviderData = jest.spyOn(LmsApiService, 'getProviderData'); diff --git a/src/components/test/testUtils.jsx b/src/components/test/testUtils.jsx index 539cd0a3ad..b5ac1bf34e 100644 --- a/src/components/test/testUtils.jsx +++ b/src/components/test/testUtils.jsx @@ -3,7 +3,7 @@ import React from 'react'; import { Router } from 'react-router-dom'; import { createMemoryHistory } from 'history'; -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; export function renderWithRouter( ui, @@ -30,3 +30,5 @@ export function findElementWithText(container, type, text) { const elements = container.querySelectorAll(type); return [...elements].find((elem) => elem.innerHTML.includes(text)); } + +export const getButtonElement = (buttonText) => screen.getByRole('button', { name: buttonText });