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..2765df53a6 --- /dev/null +++ b/src/components/settings/SettingsSSOTab/NewExistingSSOConfigs.jsx @@ -0,0 +1,196 @@ +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(); + + useQuery({ + queryKey: ['todos'], + 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: ['todos'] }); + } + + return res.data; + }, + // Refetch the data every second + refetchInterval: intervalMs, + enabled: queryForTestedConfigs || queryForConfiguredConfigs, + refetchOnWindowFocus: true, + }); + + useEffect(() => { + const inactive = configs.filter(config => config.active === false); + const active = configs.filter(config => config.active === true); + 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]); + + return ( + <> + {!loading && ( + <> + {showAlerts && ( + + )} + {activeConfigs.length > 0 && ( +
+

Active

+ + {activeConfigs.map((config) => ( + + ))} + +
+ )} + {inactiveConfigs.length > 0 && ( +
+

Inactive

+ + {inactiveConfigs.map((config) => ( + + ))} + +
+ )} + + )} + {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..6bffe32f63 --- /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..e9d58fd522 --- /dev/null +++ b/src/components/settings/SettingsSSOTab/NewSSOConfigCard.jsx @@ -0,0 +1,176 @@ +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 { 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); + }); + }; + + return ( + + + {config.validated_at && config.active && ( + The integration is verified and working} + > + + + )} + {!config.validated_at && ( + This integration has not been validated. Please follow the testing instructions to validate your integration. + )} + > + + + )} + {config.validated_at && !config.active && ( + + )} + {config.display_name} + {(!config.validated_at || config.submitted_at > config.configured_at) && ( + In-progress + )} + {config.validated_at && config.submitted_at < config.configured_at && !config.active && ( + Disabled + )} + {!config.validated_at && ( + !config.configured_at || config.submitted_at > config.configured_at) && ( + !config.active) && ( + + )} + {config.validated_at && ( + !config.configured_at || config.submitted_at > config.configured_at) && ( + !config.active) && ( + + )} + + )} + subtitle={( +
+ Last modified {convertToReadableDate(config.modified)} +
+ )} + actions={(!config.configured_at || config.submitted_at > config.configured_at) && ( + + + + {config.validated_at && ( + setProviderConfig(config)} + > + Configure + + )} + {!config.active && ( + onDeleteClick(config)} + > + Delete + + )} + {config.active && ( + 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..49b94597f2 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,39 +24,115 @@ 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); + + const newConfigurationButtonOnClick = async () => { + for (let i = 0; i < existingConfigs.length; i++) { + LmsApiService.updateEnterpriseSsoOrchestrationRecord( + { active: false, is_removed: true }, + existingConfigs[i].uuid, + ).then(() => { + setRefreshBool(!refreshBool); + close(); + }); + } + }; useEffect(() => { - let validConfigExists = false; - existingConfigs.forEach(config => { - if (config.was_valid_at) { - validConfigExists = true; - } - }); + if (AUTH0_SELF_SERVICE_INTEGRATION) { + let validConfigExists = false; + existingConfigs.forEach(config => { + if (config.validated_at) { + validConfigExists = true; + } + }); + setHasSSOConfig(validConfigExists); + } else { + let validConfigExists = false; + existingConfigs.forEach(config => { + if (config.was_valid_at) { + validConfigExists = true; + } + }); + setHasSSOConfig(validConfigExists); + } if (!existingConfigs || existingConfigs?.length < 1) { setShowNoSSOCard(true); } else { setShowNoSSOCard(false); } - setHasSSOConfig(validConfigExists); - }, [existingConfigs, setHasSSOConfig]); + }, [AUTH0_SELF_SERVICE_INTEGRATION, existingConfigs, setHasSSOConfig]); 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 + + + + +
-

SAML Configuration

- - Help Center - + {!AUTH0_SELF_SERVICE_INTEGRATION && ( +

SAML Configuration

+ )} + {AUTH0_SELF_SERVICE_INTEGRATION && ( +

Single Sign-On (SSO) Integrations

+ )} +
+ {existingConfigs?.length > 0 && (providerConfig === null) && AUTH0_SELF_SERVICE_INTEGRATION && ( + + )} + + 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) + {existingConfigs?.length > 0 && (providerConfig === null) && (!AUTH0_SELF_SERVICE_INTEGRATION) && ( { setRefreshBool={setRefreshBool} /> )} + {existingConfigs?.length > 0 && (providerConfig === null) && (AUTH0_SELF_SERVICE_INTEGRATION) + && ( + + )} {/* 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 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 2ed35e8274..b7c1e0aa91 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}`);