diff --git a/.env.development b/.env.development index 9e9f84cf22..8a4494712a 100644 --- a/.env.development +++ b/.env.development @@ -52,3 +52,4 @@ USE_API_CACHE='true' SUBSCRIPTION_LPR='true' PLOTLY_SERVER_URL='http://localhost:8050' AUTH0_SELF_SERVICE_INTEGRATION='true' +FEATURE_SSO_SETTINGS_TAB='true' diff --git a/package-lock.json b/package-lock.json index ea06117091..fe42c17d03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@edx/frontend-enterprise-utils": "3.2.0", "@edx/frontend-platform": "4.0.1", "@edx/paragon": "20.39.2", + "@tanstack/react-query": "^4.35.7", "algoliasearch": "4.8.3", "axios-mock-adapter": "1.19.0", "classnames": "2.2.6", @@ -5553,6 +5554,41 @@ "url": "https://github.com/sponsors/gregberge" } }, + "node_modules/@tanstack/query-core": { + "version": "4.35.7", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.35.7.tgz", + "integrity": "sha512-PgDJtX75ubFS0WCYFM7DqEoJ4QbxU3S5OH3gJSI40xr7UVVax3/J4CM3XUMOTs+EOT5YGEfssi3tfRVGte4DEw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "4.35.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.35.7.tgz", + "integrity": "sha512-0MankquP/6EOM2ATfEov6ViiKemey5uTbjGlFMX1xGotwNaqC76YKDMJdHumZupPbZcZPWAeoPGEHQmVKIKoOQ==", + "dependencies": { + "@tanstack/query-core": "4.35.7", + "use-sync-external-store": "^1.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/@testing-library/dom": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz", @@ -22817,6 +22853,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index c358cbf26d..fbd1b8ffac 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@edx/frontend-enterprise-utils": "3.2.0", "@edx/frontend-platform": "4.0.1", "@edx/paragon": "20.39.2", + "@tanstack/react-query": "^4.35.7", "algoliasearch": "4.8.3", "axios-mock-adapter": "1.19.0", "classnames": "2.2.6", diff --git a/src/components/settings/SettingsSSOTab/NewExistingSSOConfigs.jsx b/src/components/settings/SettingsSSOTab/NewExistingSSOConfigs.jsx new file mode 100644 index 0000000000..6474ef6a59 --- /dev/null +++ b/src/components/settings/SettingsSSOTab/NewExistingSSOConfigs.jsx @@ -0,0 +1,181 @@ +import _ from 'lodash'; +import { + CardGrid, + Skeleton, + useToggle, +} from '@edx/paragon'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; +import { connect } from 'react-redux'; +import LmsApiService from '../../../data/services/LmsApiService'; +import NewSSOConfigAlerts from './NewSSOConfigAlerts'; +import NewSSOConfigCard from './NewSSOConfigCard'; + +const FRESH_CONFIG_POLLING_INTERVAL = 30000; +const UPDATED_CONFIG_POLLING_INTERVAL = 2000; + +const NewExistingSSOConfigs = ({ + configs, refreshBool, setRefreshBool, enterpriseId, +}) => { + const [inactiveConfigs, setInactiveConfigs] = useState([]); + const [activeConfigs, setActiveConfigs] = useState([]); + const [inProgressConfigs, setInProgressConfigs] = useState([]); + const [untestedConfigs, setUntestedConfigs] = useState([]); + const [liveConfigs, setLiveConfigs] = useState([]); + const [notConfiguredConfigs, setNotConfiguredConfigs] = useState([]); + const [queryForTestedConfigs, setQueryForTestedConfigs] = useState(false); + const [queryForConfiguredConfigs, setQueryForConfiguredConfigs] = useState(false); + const [intervalMs, setIntervalMs] = React.useState(FRESH_CONFIG_POLLING_INTERVAL); + const [loading, setLoading] = useState(false); + const [showAlerts, openAlerts, closeAlerts] = useToggle(false); + + const queryClient = useQueryClient(); + + const renderCards = (gridTitle, configList) => { + if (configList.length > 0) { + return ( +
+

{gridTitle}

+ + {configList.map((config) => ( + + ))} + +
+ ); + } + return null; + }; + + useEffect(() => { + const [active, inactive] = _.partition(configs, config => config.active); + const inProgress = configs.filter( + config => (config.submitted_at && !config.configured_at) || (config.configured_at < config.submitted_at), + ); + const untested = configs.filter(config => !config.validated_at); + const live = configs.filter( + config => (config.validated_at && config.active && config.validated_at > config.configured_at), + ); + const notConfigured = configs.filter(config => !config.configured_at); + + if (live.length >= 1) { + setLiveConfigs(live); + openAlerts(); + } + + setUntestedConfigs(untested); + if (untested.length >= 1) { + setQueryForTestedConfigs(true); + openAlerts(); + } + setInProgressConfigs(inProgress); + if (inProgress.length >= 1) { + const beenConfigured = inProgress.filter(config => config.configured_at); + if (beenConfigured.length >= 1) { + setIntervalMs(UPDATED_CONFIG_POLLING_INTERVAL); + } + setQueryForConfiguredConfigs(true); + openAlerts(); + } + + if (notConfigured.length >= 1) { + setNotConfiguredConfigs(notConfigured); + } + + setActiveConfigs(active); + setInactiveConfigs(inactive); + setLoading(false); + }, [configs, refreshBool, openAlerts]); + + useQuery({ + queryKey: ['ssoOrchestratorConfigPoll'], + queryFn: async () => { + const res = await LmsApiService.listEnterpriseSsoOrchestrationRecords(enterpriseId); + const inProgress = res.data.filter( + config => (config.submitted_at && !config.configured_at) || (config.configured_at < config.submitted_at), + ); + const untested = res.data.filter(config => !config.validated_at || config.validated_at < config.configured_at); + + if (queryForConfiguredConfigs) { + if (inProgress.length === 0) { + setRefreshBool(!refreshBool); + setQueryForConfiguredConfigs(false); + } + } + + if (queryForTestedConfigs) { + if (untested.length === 0) { + setRefreshBool(!refreshBool); + setQueryForTestedConfigs(false); + } + } + + if (inProgress.length === 0 && untested.length === 0) { + queryClient.invalidateQueries({ queryKey: ['ssoOrchestratorConfigPoll'] }); + } + + return res.data; + }, + refetchInterval: intervalMs, + enabled: queryForTestedConfigs || queryForConfiguredConfigs, + refetchOnWindowFocus: true, + }); + + return ( + <> + {!loading && ( + <> + {showAlerts && ( + + )} + {renderCards('Active', activeConfigs)} + {renderCards('Inactive', inactiveConfigs)} + + )} + {loading && ( +
+ + + + +
+ )} + + ); +}; + +NewExistingSSOConfigs.propTypes = { + configs: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + refreshBool: PropTypes.bool.isRequired, + setRefreshBool: PropTypes.func.isRequired, + enterpriseId: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +export default connect(mapStateToProps)(NewExistingSSOConfigs); diff --git a/src/components/settings/SettingsSSOTab/NewSSOConfigAlerts.jsx b/src/components/settings/SettingsSSOTab/NewSSOConfigAlerts.jsx new file mode 100644 index 0000000000..01fd8e047e --- /dev/null +++ b/src/components/settings/SettingsSSOTab/NewSSOConfigAlerts.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { + CheckCircle, Warning, +} from '@edx/paragon/icons'; +import { Alert } from '@edx/paragon'; + +const NewSSOConfigAlerts = ({ + inProgressConfigs, + untestedConfigs, + liveConfigs, + notConfigured, + contactEmail, + closeAlerts, +}) => ( + <> + {inProgressConfigs.length >= 1 && ( + + Your SSO Integration is in progress +

+ edX is configuring your SSO. This step takes approximately{' '} + {notConfigured.length > 0 ? `five minutes. You will receive an email at ${contactEmail} when the configuration is complete` : 'fifteen seconds'}. +

+
+ )} + {untestedConfigs.length >= 1 && inProgressConfigs.length === 0 && ( + + You need to test your SSO connection +

+ Your SSO configuration has completed, + and you should have received an email with the following instructions:
+
+ 1. Copy the URL for your learner Portal dashboard below:
+
+   http://courses.edx.org/dashboard?tpa_hint=saml-bestrun-hana
+
+ 2: Launch a new incognito or private window and paste the copied URL into the URL bar to load your + learner Portal dashboard.
+
+ 3: When prompted, enter login credentials supported by your IDP to test your connection to edX.
+
+ Return to this window after completing the testing instructions. + This window will automatically update when a successful test is detected.
+

+
+ )} + {liveConfigs.length >= 1 && inProgressConfigs.length === 0 && untestedConfigs.length === 0 && ( + + Your SSO integration is live! +

+ Great news! Your test was successful and your new SSO integration is live and ready to use. +

+
+ )} + +); + +NewSSOConfigAlerts.propTypes = { + inProgressConfigs: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + untestedConfigs: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + liveConfigs: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + notConfigured: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + closeAlerts: PropTypes.func.isRequired, + contactEmail: PropTypes.string.isRequired, +}; + +const mapStateToProps = state => ({ + contactEmail: state.portalConfiguration.contactEmail, +}); + +export default connect(mapStateToProps)(NewSSOConfigAlerts); diff --git a/src/components/settings/SettingsSSOTab/NewSSOConfigCard.jsx b/src/components/settings/SettingsSSOTab/NewSSOConfigCard.jsx new file mode 100644 index 0000000000..9e83ef4b21 --- /dev/null +++ b/src/components/settings/SettingsSSOTab/NewSSOConfigCard.jsx @@ -0,0 +1,215 @@ +import React, { useContext } from 'react'; +import { + Card, Badge, Button, Dropdown, IconButton, Icon, Tooltip, OverlayTrigger, +} from '@edx/paragon'; +import PropTypes from 'prop-types'; +import { + Key, KeyOff, MoreVert, +} from '@edx/paragon/icons'; +import { SSOConfigContext } from './SSOConfigContext'; +import LmsApiService from '../../../data/services/LmsApiService'; + +const NewSSOConfigCard = ({ + config, + setLoading, + setRefreshBool, + refreshBool, +}) => { + const VALIDATED = config.validated_at; + const ENABLED = config.active; + const CONFIGURED = config.configured_at && (config.submitted_at < config.configured_at); + const SUBMITTED = config.submitted_at; + + const { setProviderConfig } = useContext(SSOConfigContext); + + const convertToReadableDate = (date) => { + const dateObj = new Date(date); + const options = { year: 'numeric', month: 'short', day: 'numeric' }; + return new Intl.DateTimeFormat('en-US', options).format(dateObj); + }; + + const onDeleteClick = (deletedConfig) => { + setLoading(true); + LmsApiService.deleteEnterpriseSsoOrchestrationRecord(deletedConfig.uuid).then(() => { + setRefreshBool(!refreshBool); + }); + }; + + const onDisableClick = (disabledConfig) => { + setLoading(true); + LmsApiService.updateEnterpriseSsoOrchestrationRecord({ active: false }, disabledConfig.uuid).then(() => { + setRefreshBool(!refreshBool); + }); + }; + + const onEnableClick = (enabledConfig) => { + setLoading(true); + LmsApiService.updateEnterpriseSsoOrchestrationRecord({ active: true }, enabledConfig.uuid).then(() => { + setRefreshBool(!refreshBool); + }); + }; + + const renderCardStatusIcon = () => ( + <> + {VALIDATED && ENABLED && ( + The integration is verified and working} + > + + + )} + {!VALIDATED && ( + This integration has not been validated. Please follow the testing instructions to validate your integration. + )} + > + + + )} + {VALIDATED && !ENABLED && ( + + )} + + ); + + const renderCardBadge = () => ( + <> + {(!VALIDATED && SUBMITTED) && ( + + In-progress + + )} + {VALIDATED && CONFIGURED && !ENABLED && ( + + Disabled + + )} + + ); + + const renderCardButton = () => ( + <> + {!VALIDATED && CONFIGURED && ( + + )} + {VALIDATED && CONFIGURED && !ENABLED && ( + + )} + + ); + + return ( + + + {renderCardStatusIcon()} + {config.display_name} + {renderCardBadge()} + {renderCardButton()} + + )} + subtitle={( +
+ Last modified {convertToReadableDate(config.modified)} +
+ )} + actions={(!SUBMITTED || CONFIGURED) && ( + + + + {VALIDATED && ( + setProviderConfig(config)} + > + Configure + + )} + {(!ENABLED || !VALIDATED) && ( + onDeleteClick(config)} + > + Delete + + )} + {ENABLED && VALIDATED && ( + onDisableClick(config)} + > + Disable + + )} + + + )} + /> +
+ ); +}; + +NewSSOConfigCard.propTypes = { + config: PropTypes.shape({ + uuid: PropTypes.string, + display_name: PropTypes.string, + active: PropTypes.bool, + modified: PropTypes.string, + validated_at: PropTypes.string, + configured_at: PropTypes.string, + submitted_at: PropTypes.string, + }).isRequired, + setLoading: PropTypes.func.isRequired, + setRefreshBool: PropTypes.func.isRequired, + refreshBool: PropTypes.bool.isRequired, +}; + +export default NewSSOConfigCard; diff --git a/src/components/settings/SettingsSSOTab/hooks.js b/src/components/settings/SettingsSSOTab/hooks.js index 04a2002af8..4069de6804 100644 --- a/src/components/settings/SettingsSSOTab/hooks.js +++ b/src/components/settings/SettingsSSOTab/hooks.js @@ -10,6 +10,7 @@ import { updateIdpDirtyState, } from './data/actions'; import { updateSamlProviderData, deleteSamlProviderData } from './utils'; +import { features } from '../../../config'; const useIdpState = () => { const { @@ -179,23 +180,43 @@ const useExistingSSOConfigs = (enterpriseUuid, refreshBool) => { const [error, setError] = useState(null); useEffect(() => { + const { AUTH0_SELF_SERVICE_INTEGRATION } = features; if (enterpriseUuid) { - const fetchConfig = async () => { - const response = await LmsApiService.getProviderConfig(enterpriseUuid); - return response.data.results; - }; - fetchConfig().then(configs => { - setSsoConfigs(configs); - setLoading(false); - }).catch(err => { - setLoading(false); - if (err.customAttributes?.httpErrorStatus !== 404) { - // nothing found is okay for this fetcher. - setError(err); - } else { - setSsoConfigs([]); - } - }); + if (!AUTH0_SELF_SERVICE_INTEGRATION) { + const fetchConfig = async () => { + const response = await LmsApiService.getProviderConfig(enterpriseUuid); + return response.data.results; + }; + fetchConfig().then(configs => { + setSsoConfigs(configs); + setLoading(false); + }).catch(err => { + setLoading(false); + if (err.customAttributes?.httpErrorStatus !== 404) { + // nothing found is okay for this fetcher. + setError(err); + } else { + setSsoConfigs([]); + } + }); + } else { + const fetchConfig = async () => { + const response = await LmsApiService.listEnterpriseSsoOrchestrationRecords(enterpriseUuid); + return response.data; + }; + fetchConfig().then(orchestratorConfigs => { + setSsoConfigs(orchestratorConfigs); + setLoading(false); + }).catch(err => { + setLoading(false); + if (err.customAttributes?.httpErrorStatus !== 404) { + // nothing found is okay for this fetcher. + setError(err); + } else { + setSsoConfigs([]); + } + }); + } } }, [enterpriseUuid, refreshBool]); diff --git a/src/components/settings/SettingsSSOTab/index.jsx b/src/components/settings/SettingsSSOTab/index.jsx index ff17bfe254..b1181c6817 100644 --- a/src/components/settings/SettingsSSOTab/index.jsx +++ b/src/components/settings/SettingsSSOTab/index.jsx @@ -1,15 +1,18 @@ import React, { useContext, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { - Alert, Hyperlink, Toast, Skeleton, + Alert, ActionRow, Button, Hyperlink, ModalDialog, Toast, Skeleton, useToggle, } from '@edx/paragon'; -import { WarningFilled } from '@edx/paragon/icons'; +import { Add, WarningFilled } from '@edx/paragon/icons'; import { HELP_CENTER_SAML_LINK } from '../data/constants'; import { useExistingSSOConfigs, useExistingProviderData } from './hooks'; import NoSSOCard from './NoSSOCard'; import ExistingSSOConfigs from './ExistingSSOConfigs'; +import NewExistingSSOConfigs from './NewExistingSSOConfigs'; import NewSSOConfigForm from './NewSSOConfigForm'; import { SSOConfigContext, SSOConfigContextProvider } from './SSOConfigContext'; +import LmsApiService from '../../../data/services/LmsApiService'; +import { features } from '../../../config'; const SettingsSSOTab = ({ enterpriseId, setHasSSOConfig }) => { const { @@ -21,22 +24,133 @@ const SettingsSSOTab = ({ enterpriseId, setHasSSOConfig }) => { const [existingProviderData, pdError, pdIsLoading] = useExistingProviderData(enterpriseId, refreshBool); const [showNewSSOForm, setShowNewSSOForm] = useState(false); const [showNoSSOCard, setShowNoSSOCard] = useState(false); + const { AUTH0_SELF_SERVICE_INTEGRATION } = features; + const [isOpen, open, close] = useToggle(false); - useEffect(() => { - let validConfigExists = false; - existingConfigs.forEach(config => { - if (config.was_valid_at) { - validConfigExists = true; - } + const newConfigurationButtonOnClick = async () => { + Promise.all(existingConfigs.map(config => LmsApiService.updateEnterpriseSsoOrchestrationRecord( + { active: false, is_removed: true }, + config.uuid, + ))).then(() => { + setRefreshBool(!refreshBool); + close(); }); + }; + + useEffect(() => { + if (AUTH0_SELF_SERVICE_INTEGRATION) { + setHasSSOConfig(existingConfigs.some(config => config.validated_at)); + } else { + setHasSSOConfig(existingConfigs.some(config => config.was_valid_at)); + } if (!existingConfigs || existingConfigs?.length < 1) { setShowNoSSOCard(true); } else { setShowNoSSOCard(false); } - setHasSSOConfig(validConfigExists); - }, [existingConfigs, setHasSSOConfig]); + }, [AUTH0_SELF_SERVICE_INTEGRATION, existingConfigs, setHasSSOConfig]); + if (AUTH0_SELF_SERVICE_INTEGRATION) { + return ( +
+ + + + Create new SSO configuration? + + + +

+ Only one SSO integration is supported at a time.
+
+ To continue updating and editing your SSO integration, select "Cancel" and then + "Configure" on the integration card. Creating a new SSO configuration will overwrite and delete + your existing SSO configuration. +

+
+ + + + Cancel + + + + +
+
+

Single Sign-On (SSO) Integrations

+
+ {existingConfigs?.length > 0 && (providerConfig === null) && ( + + )} + + Help Center: Single Sign-On + +
+
+ {(!isLoading || !pdIsLoading) && ( +
+ {/* providerConfig represents the currently selected config to edit/create, if there are + existing configs but no providerConfig then we can safely render the listings page */} + {existingConfigs?.length > 0 && (providerConfig === null) && ( + + )} + {/* Nothing found so guide user to creation/edit form */} + {showNoSSOCard && } + {/* Since we found a selected providerConfig we know we are in editing mode and can safely + render the create/edit form */} + {((existingConfigs?.length > 0 && providerConfig !== null) || showNewSSOForm) && ()} + {error && ( + + An error occurred loading the SAML configs:

{error?.message}

+
+ )} + {pdError && ( + + An error occurred loading the SAML data:

{pdError?.message}

+
+ )} + {infoMessage && ( + setInfoMessage(null)} + show={infoMessage.length > 0} + > + {infoMessage} + + )} +
+ )} + {(isLoading || pdIsLoading) && } +
+ ); + } return (
diff --git a/src/components/settings/SettingsSSOTab/tests/NewExistingSSOConfigs.test.jsx b/src/components/settings/SettingsSSOTab/tests/NewExistingSSOConfigs.test.jsx new file mode 100644 index 0000000000..cd0b7dfdd1 --- /dev/null +++ b/src/components/settings/SettingsSSOTab/tests/NewExistingSSOConfigs.test.jsx @@ -0,0 +1,245 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import { + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query'; +import userEvent from '@testing-library/user-event'; +import { + act, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import { Provider } from 'react-redux'; +import { getMockStore, enterpriseId } from '../testutils'; +import { features } from '../../../../config'; +import NewExistingSSOConfigs from '../NewExistingSSOConfigs'; +import { SSOConfigContext, SSO_INITIAL_STATE } from '../SSOConfigContext'; +import LmsApiService from '../../../../data/services/LmsApiService'; + +const queryClient = new QueryClient({ + queries: { + retry: true, // optional: you may disable automatic query retries for all queries or on a per-query basis. + }, +}); + +jest.mock('../../utils'); +jest.mock('../../../../data/services/LmsApiService'); +const mockSetRefreshBool = jest.fn(); + +const initialStore = { + portalConfiguration: { + enterpriseId, + enterpriseSlug: 'sluggy', + enterpriseName: 'sluggyent', + contactEmail: 'foobar', + }, +}; +const store = getMockStore({ contactEmail: 'foobar', ...initialStore }); +const inactiveConfig = [ + { + uuid: 'ecc16800-c1cc-4cdb-93aa-186f71b026ca', + display_name: 'foobar', + active: false, + modified: '2022-04-12T19:51:25Z', + configured_at: '2022-05-12T19:51:25Z', + validated_at: '2022-06-12T19:51:25Z', + submitted_at: '2022-04-12T19:51:25Z', + }, +]; +const activeConfig = [ + { + uuid: 'ecc16800-c1cc-4cdb-93aa-186f71b026ca', + display_name: 'foobar', + active: true, + modified: '2022-04-12T19:51:25Z', + configured_at: '2022-05-12T19:51:25Z', + validated_at: '2022-06-12T19:51:25Z', + submitted_at: '2022-04-12T19:51:25Z', + }, +]; +const unvalidatedConfig = [ + { + uuid: 'ecc16800-c1cc-4cdb-93aa-186f71b026ca', + display_name: 'foobar', + active: true, + modified: '2022-04-12T19:51:25Z', + configured_at: '2022-04-12T19:51:25Z', + validated_at: null, + submitted_at: '2022-04-12T19:51:25Z', + }, +]; +const inProgressConfig = [ + { + uuid: 'ecc16800-c1cc-4cdb-93aa-186f71b026ca', + display_name: 'foobar', + active: false, + modified: '2022-04-12T19:51:25Z', + configured_at: '2021-04-12T19:51:25Z', + validated_at: null, + submitted_at: '2022-04-12T19:51:25Z', + }, +]; +const notConfiguredConfig = [ + { + uuid: 'ecc16800-c1cc-4cdb-93aa-186f71b026ca', + display_name: 'foobar', + active: false, + modified: '2022-04-12T19:51:25Z', + configured_at: null, + validated_at: null, + submitted_at: '2022-04-12T19:51:25Z', + }, +]; + +jest.mock('../data/actions'); +jest.mock('../../utils'); +const entryType = 'direct'; +const metadataURL = 'https://foobar.com'; +const entityID = 'foobar'; +const publicKey = 'abc123'; +const ssoUrl = 'https://foobar.com'; +const mockCreateOrUpdateIdpRecord = jest.fn(); +const mockHandleEntityIDUpdate = jest.fn(); +const mockHandleMetadataEntryTypeUpdate = jest.fn(); +jest.mock('../hooks', () => { + const originalModule = jest.requireActual('../hooks'); + return { + ...originalModule, + useIdpState: () => ({ + entryType, + metadataURL, + entityID, + publicKey, + ssoUrl, + createOrUpdateIdpRecord: mockCreateOrUpdateIdpRecord, + handleEntityIDUpdate: mockHandleEntityIDUpdate, + handleMetadataEntryTypeUpdate: mockHandleMetadataEntryTypeUpdate, + }), + useExistingSSOConfigs: () => [[{ hehe: 'haha' }], null, true], + }; +}); + +const mockSetProviderConfig = jest.fn(); +const contextValue = { + ...SSO_INITIAL_STATE, + setCurrentError: jest.fn(), + currentError: null, + dispatchSsoState: jest.fn(), + ssoState: { + idp: { + metadataURL: '', + entityID: '', + entryType: '', + isDirty: false, + }, + serviceprovider: { + isSPConfigured: false, + }, + refreshBool: false, + providerConfig: { + id: 1337, + }, + }, + setProviderConfig: mockSetProviderConfig, + setRefreshBool: jest.fn(), +}; + +const setupNewExistingSSOConfigs = (configs) => { + features.AUTH0_SELF_SERVICE_INTEGRATION = true; + return render( + + + + + + + + + , + ); +}; + +describe('New Existing SSO Configs tests', () => { + afterEach(() => { + features.AUTH0_SELF_SERVICE_INTEGRATION = false; + jest.clearAllMocks(); + }); + test('checks and sets in progress configs', async () => { + setupNewExistingSSOConfigs(inProgressConfig); + expect( + screen.queryByText( + 'Your SSO Integration is in progress', + ), + ).toBeInTheDocument(); + }); + test('checks and sets not configured configs', async () => { + setupNewExistingSSOConfigs(notConfiguredConfig); + expect( + screen.queryByText( + 'Your SSO Integration is in progress', + ), + ).toBeInTheDocument(); + }); + test('checks and sets validated configs', async () => { + setupNewExistingSSOConfigs(activeConfig); + expect( + screen.queryByText( + 'Your SSO integration is live!', + ), + ).toBeInTheDocument(); + }); + test('checks and sets un-validated configs', async () => { + setupNewExistingSSOConfigs(unvalidatedConfig); + expect( + screen.queryByText( + 'You need to test your SSO connection', + ), + ).toBeInTheDocument(); + }); + test('polls for finished configs', async () => { + const spy = jest.spyOn(LmsApiService, 'listEnterpriseSsoOrchestrationRecords'); + spy.mockImplementation(() => Promise.resolve({ + data: [{ + uuid: 'ecc16800-c1cc-4cdb-93aa-186f71b026ca', + display_name: 'foobar', + active: true, + modified: '2022-04-12T19:51:25Z', + configured_at: '2022-05-12T19:51:25Z', + validated_at: '2022-06-12T19:51:25Z', + submitted_at: '2022-04-12T19:51:25Z', + }], + })); + setupNewExistingSSOConfigs(inProgressConfig); + expect( + screen.queryByText( + 'Your SSO Integration is in progress', + ), + ).toBeInTheDocument(); + await waitFor(() => expect(spy).toHaveBeenCalledTimes(1)); + expect(mockSetRefreshBool).toHaveBeenCalledTimes(2); + }); + test('enabling config sets loading and renders skeleton', async () => { + const spy = jest.spyOn(LmsApiService, 'updateEnterpriseSsoOrchestrationRecord'); + spy.mockImplementation(() => Promise.resolve({})); + setupNewExistingSSOConfigs(inactiveConfig); + const button = screen.getByTestId('existing-sso-config-card-enable-button'); + act(() => { + userEvent.click(button); + }); + expect(spy).toBeCalledTimes(1); + await waitFor(() => expect( + screen.queryByTestId( + 'sso-self-service-skeleton', + ), + ).toBeInTheDocument()); + }); +}); diff --git a/src/components/settings/SettingsSSOTab/tests/NewSSOConfigAlerts.test.jsx b/src/components/settings/SettingsSSOTab/tests/NewSSOConfigAlerts.test.jsx new file mode 100644 index 0000000000..c62c7972ac --- /dev/null +++ b/src/components/settings/SettingsSSOTab/tests/NewSSOConfigAlerts.test.jsx @@ -0,0 +1,165 @@ +import React from '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 { render, screen, waitFor } from '@testing-library/react'; +import { SSOConfigContext, SSO_INITIAL_STATE } from '../SSOConfigContext'; +import { getMockStore, initialStore } from '../testutils'; +import NewSSOConfigAlerts from '../NewSSOConfigAlerts'; + +const store = getMockStore({ contactEmail: 'foobar', ...initialStore }); +const mockSetProviderConfig = jest.fn(); +const contextValue = { + ...SSO_INITIAL_STATE, + setCurrentError: jest.fn(), + currentError: null, + dispatchSsoState: jest.fn(), + ssoState: { + idp: { + metadataURL: '', + entityID: '', + entryType: '', + isDirty: false, + }, + serviceprovider: { + isSPConfigured: false, + }, + refreshBool: false, + providerConfig: { + id: 1337, + }, + }, + setProviderConfig: mockSetProviderConfig, + setRefreshBool: jest.fn(), +}; + +describe('New SSO Config Alerts Tests', () => { + test('displays inProgress alert properly', async () => { + render( + + + + , + + + , + ); + expect( + screen.queryByText( + 'Your SSO Integration is in progress', + ), + ).toBeInTheDocument(); + expect( + screen.queryByText( + 'You need to test your SSO connection', + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByText( + 'Your SSO integration is live!', + ), + ).not.toBeInTheDocument(); + }); + test('inProgress alert accounts for if configured before', () => { + render( + + + + , + + + , + ); + expect( + screen.getByText( + 'five minutes', + { exact: false }, + ), + ).toBeInTheDocument(); + }); + test('displays untested alert properly', () => { + render( + + + + , + + + , + ); + expect( + screen.queryByText( + 'You need to test your SSO connection', + ), + ).toBeInTheDocument(); + expect( + screen.queryByText( + 'Your SSO integration is live!', + ), + ).not.toBeInTheDocument(); + }); + test('displays live alert properly', () => { + render( + + + + , + + + , + ); + expect( + screen.queryByText( + 'Your SSO integration is live!', + ), + ).toBeInTheDocument(); + }); + test('calls closeAlerts prop on close', async () => { + const mockCloseAlerts = jest.fn(); + render( + + + + , + + + , + ); + await waitFor(() => { + userEvent.click(screen.getByText('Dismiss')); + }, []).then(() => { + expect(mockCloseAlerts).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/settings/SettingsSSOTab/tests/NewSSOConfigCard.test.jsx b/src/components/settings/SettingsSSOTab/tests/NewSSOConfigCard.test.jsx new file mode 100644 index 0000000000..a953ecb752 --- /dev/null +++ b/src/components/settings/SettingsSSOTab/tests/NewSSOConfigCard.test.jsx @@ -0,0 +1,222 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import userEvent from '@testing-library/user-event'; +import { act, render, screen } from '@testing-library/react'; +import NewSSOConfigCard from '../NewSSOConfigCard'; +import LmsApiService from '../../../../data/services/LmsApiService'; + +describe('New SSO Config Card Tests', () => { + test('displays enabled and validated status icon properly', async () => { + render( + , + ); + expect( + screen.getByTestId( + 'existing-sso-config-card-enabled-icon', + ), + ).toBeInTheDocument(); + }); + test('displays not validated status icon properly', async () => { + render( + , + ); + expect( + screen.getByTestId( + 'existing-sso-config-card-not-validated-icon', + ), + ).toBeInTheDocument(); + }); + test('displays not validated status icon properly', async () => { + render( + , + ); + expect( + screen.getByTestId( + 'existing-sso-config-card-not-active-icon', + ), + ).toBeInTheDocument(); + }); + test('displays badges properly', async () => { + render( + , + ); + expect( + screen.getByTestId( + 'existing-sso-config-card-badge-in-progress', + ), + ).toBeInTheDocument(); + render( + , + ); + expect( + screen.getByTestId( + 'existing-sso-config-card-badge-disabled', + ), + ).toBeInTheDocument(); + }); + test('displays configure button properly', async () => { + render( + , + ); + expect( + screen.getByTestId( + 'existing-sso-config-card-configure-button', + ), + ).toBeInTheDocument(); + }); + test('displays enable button properly', async () => { + render( + , + ); + expect( + screen.getByTestId( + 'existing-sso-config-card-enable-button', + ), + ).toBeInTheDocument(); + }); + test('handles kebob Delete dropdown option', async () => { + const spy = jest.spyOn(LmsApiService, 'deleteEnterpriseSsoOrchestrationRecord'); + spy.mockImplementation(() => Promise.resolve({})); + render( + , + ); + act(() => { + userEvent.click(screen.getByTestId('existing-sso-config-card-dropdown')); + }); + act(() => { + userEvent.click(screen.getByTestId('existing-sso-config-delete-dropdown')); + }); + expect(spy).toBeCalledTimes(1); + }); + test('handles kebob Disable dropdown option', async () => { + const spy = jest.spyOn(LmsApiService, 'updateEnterpriseSsoOrchestrationRecord'); + spy.mockImplementation(() => Promise.resolve({})); + render( + , + ); + act(() => { + userEvent.click(screen.getByTestId('existing-sso-config-card-dropdown')); + }); + act(() => { + userEvent.click(screen.getByTestId('existing-sso-config-disable-dropdown')); + }); + expect(spy).toBeCalledTimes(1); + }); +}); diff --git a/src/components/settings/SettingsSSOTab/tests/SettingsSSOTab.test.jsx b/src/components/settings/SettingsSSOTab/tests/SettingsSSOTab.test.jsx index 4a05d2b026..a46e4697d8 100644 --- a/src/components/settings/SettingsSSOTab/tests/SettingsSSOTab.test.jsx +++ b/src/components/settings/SettingsSSOTab/tests/SettingsSSOTab.test.jsx @@ -1,12 +1,18 @@ import { act, render, screen, waitFor, } from '@testing-library/react'; +import { + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query'; import '@testing-library/jest-dom/extend-expect'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import { Provider } from 'react-redux'; import { HELP_CENTER_SAML_LINK } from '../../data/constants'; +import { features } from '../../../../config'; import SettingsSSOTab from '..'; import LmsApiService from '../../../../data/services/LmsApiService'; @@ -19,14 +25,24 @@ const initialStore = { enterpriseId, enterpriseSlug: 'sluggy', enterpriseName: 'sluggyent', + contactEmail: 'foobar', }, }; +const queryClient = new QueryClient({ + queries: { + retry: true, // optional: you may disable automatic query retries for all queries or on a per-query basis. + }, +}); const mockStore = configureMockStore([thunk]); const getMockStore = aStore => mockStore(aStore); const store = getMockStore({ ...initialStore }); describe('SAML Config Tab', () => { + afterEach(() => { + features.AUTH0_SELF_SERVICE_INTEGRATION = false; + jest.clearAllMocks(); + }); test('renders base page with correct text and help center link', async () => { const aResult = () => Promise.resolve(1); LmsApiService.getProviderConfig.mockImplementation(() => ( @@ -57,7 +73,7 @@ describe('SAML Config Tab', () => { () => expect(mockSetHasSSOConfig).toBeCalledWith(false), ); }); - test('page sets has valid sso config with valid configs ', async () => { + test('page sets has valid sso config with valid configs', async () => { LmsApiService.getProviderConfig.mockImplementation(() => ( { data: { results: [{ was_valid_at: '10/10/22' }] } } )); @@ -70,4 +86,31 @@ describe('SAML Config Tab', () => { () => expect(mockSetHasSSOConfig).toBeCalledWith(true), ); }); + test('page renders new sso self service tool properly', async () => { + features.AUTH0_SELF_SERVICE_INTEGRATION = true; + const spy = jest.spyOn(LmsApiService, 'listEnterpriseSsoOrchestrationRecords'); + spy.mockImplementation(() => Promise.resolve({ + data: [{ + uuid: 'ecc16800-c1cc-4cdb-93aa-186f71b026ca', + display_name: 'foobar', + active: true, + modified: '2022-04-12T19:51:25Z', + configured_at: '2022-05-12T19:51:25Z', + validated_at: '2022-06-12T19:51:25Z', + submitted_at: '2022-04-12T19:51:25Z', + }], + })); + await waitFor(() => render( + + + + + , + + , + )); + expect(screen.queryByText( + 'Great news! Your test was successful and your new SSO integration is live and ready to use.', + )).toBeInTheDocument(); + }); }); diff --git a/src/components/settings/SettingsTabs.jsx b/src/components/settings/SettingsTabs.jsx index 8f471233c3..729ac106bc 100644 --- a/src/components/settings/SettingsTabs.jsx +++ b/src/components/settings/SettingsTabs.jsx @@ -1,4 +1,8 @@ import React, { useState, useMemo } from 'react'; +import { + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query'; import { Container, Tabs, @@ -27,6 +31,12 @@ import SettingsApiCredentialsTab from './SettingsApiCredentialsTab'; import { features } from '../../config'; import { updatePortalConfigurationEvent } from '../../data/actions/portalConfiguration'; +const queryClient = new QueryClient({ + queries: { + retry: true, // optional: you may disable automatic query retries for all queries or on a per-query basis. + }, +}); + const SettingsTabs = ({ enterpriseId, enterpriseSlug, @@ -80,10 +90,12 @@ const SettingsTabs = ({ eventKey={SETTINGS_TABS_VALUES.sso} title={SETTINGS_TAB_LABELS.sso} > - + + + , ); } diff --git a/src/data/services/LmsApiService.js b/src/data/services/LmsApiService.js index 18f2cca502..b3c32f662d 100644 --- a/src/data/services/LmsApiService.js +++ b/src/data/services/LmsApiService.js @@ -46,7 +46,7 @@ class LmsApiService { return LmsApiService.apiClient().get(enterpriseSsoOrchestrationFetchUrl); } - static listEnterpriseSsoOrchestration(enterpriseCustomerUuid) { + static listEnterpriseSsoOrchestrationRecords(enterpriseCustomerUuid) { const enterpriseSsoOrchestrationListUrl = `${LmsApiService.enterpriseSsoOrchestrationUrl}`; if (enterpriseCustomerUuid) { return LmsApiService.apiClient().get(`${enterpriseSsoOrchestrationListUrl}?enterprise_customer=${enterpriseCustomerUuid}`);