From 648c3f9de828d61fa62bbc80d564092ae0afdbf4 Mon Sep 17 00:00:00 2001 From: Adeel Khan Date: Sat, 6 Feb 2021 02:32:38 +0500 Subject: [PATCH] Enterprise sso login via tpa hint param. VAN-42 --- src/_style.scss | 29 +++ src/common-components/EnterpriseSSO.jsx | 104 ++++++++ src/common-components/messages.jsx | 11 + src/data/utils/dataUtils.js | 18 ++ src/data/utils/index.js | 2 +- src/login/LoginPage.jsx | 56 ++++- src/login/tests/LoginPage.test.jsx | 68 +++++- src/register/RegistrationPage.jsx | 61 ++++- src/register/tests/RegistrationPage.test.jsx | 98 +++++--- .../RegistrationPage.test.jsx.snap | 225 ------------------ 10 files changed, 398 insertions(+), 274 deletions(-) create mode 100644 src/common-components/EnterpriseSSO.jsx diff --git a/src/_style.scss b/src/_style.scss index 65463d81..af3b448b 100644 --- a/src/_style.scss +++ b/src/_style.scss @@ -63,6 +63,20 @@ $apple-focus-black: $apple-black; } } +.btn-tpa { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + padding-left: 20px; + + .icon-image { + background-color: transparent; + max-height: 24px; + max-width: 24px; + } +} + .tpa-container { display: flex; flex-direction: row; @@ -249,6 +263,21 @@ $apple-focus-black: $apple-black; padding-top: 10px; } +.section-title { + width: 100%; + text-align: center; + border-bottom: 1px solid; + line-height: 0.1em; + margin-bottom: 2rem; + margin-top: 2rem; +} +.section-title span { + background:#fff; + padding:0 20px; + color:#646464; + font-size: 20px; +} + @media (min-width: 576px) { .reset-password-container { width: 420px; diff --git a/src/common-components/EnterpriseSSO.jsx b/src/common-components/EnterpriseSSO.jsx new file mode 100644 index 00000000..b23f5c38 --- /dev/null +++ b/src/common-components/EnterpriseSSO.jsx @@ -0,0 +1,104 @@ +import React from 'react'; + +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import PropTypes from 'prop-types'; +import { getConfig } from '@edx/frontend-platform'; +import { faSignInAlt } from '@fortawesome/free-solid-svg-icons'; + +import { + Form, Button, +} from '@edx/paragon'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants'; +import messages from './messages'; + +const EnterpriseSSO = (props) => { + const { intl } = props; + const tpaProvider = props.provider; + + const handleSubmit = (e, url) => { + e.preventDefault(); + window.location.href = getConfig().LMS_BASE_URL + url; + }; + + const handleClick = (e) => { + e.preventDefault(); + window.location.href = LOGIN_PAGE; + }; + + if (tpaProvider) { + return ( +
+
+
+

Sign in

+
+

{intl.formatMessage(messages['enterprisetpa.title.heading'], { providerName: tpaProvider.name })}

+ +

or

+ +
+
+
+
+ ); + } + return
; +}; + +EnterpriseSSO.defaultProps = { + provider: { + id: '', + name: '', + iconClass: '', + iconImage: '', + loginUrl: '', + registerUrl: '', + }, +}; + +EnterpriseSSO.propTypes = { + provider: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + iconClass: PropTypes.string, + iconImage: PropTypes.string, + loginUrl: PropTypes.string, + registerUrl: PropTypes.string, + }), + intl: intlShape.isRequired, +}; + +export default injectIntl(EnterpriseSSO); diff --git a/src/common-components/messages.jsx b/src/common-components/messages.jsx index 28c89415..b8970c8f 100644 --- a/src/common-components/messages.jsx +++ b/src/common-components/messages.jsx @@ -28,6 +28,17 @@ const messages = defineMessages({ defaultMessage: 'An error has occurred. Try refreshing the page, or check your Internet connection.', description: 'Error message that appears when server responds with 500 error code', }, + // enterprise sso strings + 'enterprisetpa.title.heading': { + id: 'enterprisetpa.title.heading', + defaultMessage: 'Would you like to sign in using {providerName} credentials?', + description: 'Header text used in enterprise third party authentication', + }, + 'enterprisetpa.login.button.text': { + id: 'enterprisetpa.login.button.text', + defaultMessage: 'Show me other ways to sign in or register', + description: 'Button text for login', + }, }); export default messages; diff --git a/src/data/utils/dataUtils.js b/src/data/utils/dataUtils.js index e94be8e9..7d0908f3 100644 --- a/src/data/utils/dataUtils.js +++ b/src/data/utils/dataUtils.js @@ -7,3 +7,21 @@ export default function processLink(link) { }); return matches; } + +export const getTpaProvider = (tpaHintProvider, primaryProviders, secondaryProviders) => { + let tpaProvider = null; + primaryProviders.forEach((provider) => { + if (provider.id === tpaHintProvider) { + tpaProvider = provider; + } + }); + + if (!tpaProvider) { + secondaryProviders.forEach((provider) => { + if (provider.id === tpaHintProvider) { + tpaProvider = provider; + } + }); + } + return tpaProvider; +}; diff --git a/src/data/utils/index.js b/src/data/utils/index.js index 7bca6fc4..ca817c73 100644 --- a/src/data/utils/index.js +++ b/src/data/utils/index.js @@ -1,2 +1,2 @@ -export { default } from './dataUtils'; +export { default, getTpaProvider } from './dataUtils'; export { default as AsyncActionType } from './reduxUtils'; diff --git a/src/login/LoginPage.jsx b/src/login/LoginPage.jsx index 11b84ebb..83b7f9fe 100644 --- a/src/login/LoginPage.jsx +++ b/src/login/LoginPage.jsx @@ -21,6 +21,7 @@ import { loginErrorSelector, loginRequestSelector } from './data/selectors'; import { thirdPartyAuthContextSelector } from '../common-components/data/selectors'; import LoginHelpLinks from './LoginHelpLinks'; import LoginFailureMessage from './LoginFailure'; +import EnterpriseSSO from '../common-components/EnterpriseSSO'; import messages from './messages'; import { RedirectLogistration, SocialAuthProviders, ThirdPartyAuthAlert, RenderInstitutionButton, @@ -30,6 +31,7 @@ import { DEFAULT_REDIRECT_URL, DEFAULT_STATE, LOGIN_PAGE, REGISTER_PAGE, ENTERPRISE_LOGIN_URL, PENDING_STATE, } from '../data/constants'; import { forgotPasswordResultSelector } from '../forgot-password'; +import { getTpaProvider } from '../data/utils'; class LoginPage extends React.Component { constructor(props, context) { @@ -50,9 +52,14 @@ class LoginPage extends React.Component { componentDidMount() { const params = (new URL(document.location)).searchParams; + const tpaHint = params.get('tpa_hint'); const payload = { redirect_to: params.get('next') || DEFAULT_REDIRECT_URL, }; + + if (tpaHint) { + payload.tpa_hint = tpaHint; + } this.props.getThirdPartyAuthContext(payload); } @@ -144,16 +151,16 @@ class LoginPage extends React.Component { } return thirdPartyComponent; } - render() { + renderForm(params, + currentProvider, + providers, + secondaryProviders, + thirdPartyAuthContext, + thirdPartyAuthApiStatus, + submitState, + intl) { const { email, errors, password } = this.state; - const { - intl, submitState, thirdPartyAuthContext, thirdPartyAuthApiStatus, - } = this.props; - const { currentProvider, providers, secondaryProviders } = this.props.thirdPartyAuthContext; - - const params = (new URL(window.location.href)).searchParams; const activationMsgType = params.get('account_activation_status'); - if (this.state.institutionLogin) { return ( ); } + + render() { + const { + intl, submitState, thirdPartyAuthContext, thirdPartyAuthApiStatus, + } = this.props; + const { currentProvider, providers, secondaryProviders } = this.props.thirdPartyAuthContext; + + const params = (new URL(window.location.href)).searchParams; + + const tpaHint = params.get('tpa_hint'); + if (tpaHint) { + if (thirdPartyAuthApiStatus === PENDING_STATE) { + return ; + } + const provider = getTpaProvider(tpaHint, providers, secondaryProviders); + return provider ? () : this.renderForm(params, + currentProvider, + providers, + secondaryProviders, + thirdPartyAuthContext, + thirdPartyAuthApiStatus, + submitState, + intl); + } + return this.renderForm(params, + currentProvider, + providers, + secondaryProviders, + thirdPartyAuthContext, + thirdPartyAuthApiStatus, + submitState, + intl); + } } LoginPage.defaultProps = { diff --git a/src/login/tests/LoginPage.test.jsx b/src/login/tests/LoginPage.test.jsx index fffa6120..69b68b63 100644 --- a/src/login/tests/LoginPage.test.jsx +++ b/src/login/tests/LoginPage.test.jsx @@ -13,7 +13,7 @@ import LoginFailureMessage from '../LoginFailure'; import LoginPage from '../LoginPage'; import { loginRequest, loginRequestFailure } from '../data/actions'; import { RenderInstitutionButton } from '../../common-components'; -import { PENDING_STATE } from '../../data/constants'; +import { COMPLETE_STATE, PENDING_STATE } from '../../data/constants'; jest.mock('@edx/frontend-platform/analytics'); @@ -413,4 +413,70 @@ describe('LoginPage', () => { expect(loginPage.find()).toBeTruthy(); expect(loginPage.find('LoginPage').state('isSubmitted')).toEqual(true); }); + + it('should render tpa button for tpa_hint id in primary provider', () => { + const expectedMessage = `Sign in using ${appleProvider.name}`; + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + providers: [appleProvider], + }, + thirdPartyAuthApiStatus: COMPLETE_STATE, + }, + }); + + delete window.location; + window.location = { href: getConfig().BASE_URL.concat(`/login?next=/dashboard&tpa_hint=${appleProvider.id}`) }; + appleProvider.iconImage = null; + + const loginPage = mount(reduxWrapper()); + expect(loginPage.find(`button#${appleProvider.id}`).find('span').text()).toEqual(expectedMessage); + }); + + it('should render regular tpa button for invalid tpa_hint value', () => { + const expectedMessage = `${appleProvider.name}`; + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + providers: [appleProvider], + }, + thirdPartyAuthApiStatus: COMPLETE_STATE, + }, + }); + + delete window.location; + window.location = { href: getConfig().BASE_URL.concat('/login?next=/dashboard&tpa_hint=invalid') }; + appleProvider.iconImage = null; + + const loginPage = mount(reduxWrapper()); + expect(loginPage.find(`button#${appleProvider.id}`).find('span').text()).toEqual(expectedMessage); + }); + + it('should render tpa button for tpa_hint id in secondary provider', () => { + const expectedMessage = `Sign in using ${secondaryProviders.name}`; + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + secondaryProviders: [secondaryProviders], + }, + thirdPartyAuthApiStatus: COMPLETE_STATE, + }, + }); + + delete window.location; + window.location = { href: getConfig().BASE_URL.concat(`/login?next=/dashboard&tpa_hint=${secondaryProviders.id}`) }; + secondaryProviders.iconImage = null; + + const loginPage = mount(reduxWrapper()); + expect(loginPage.find(`button#${secondaryProviders.id}`).find('span').text()).toEqual(expectedMessage); + }); }); diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 0aa1c041..628a8fe5 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -31,6 +31,7 @@ import { InstitutionLogistration, AuthnValidationFormGroup, } from '../common-components'; import RegistrationFailure from './RegistrationFailure'; +import EnterpriseSSO from '../common-components/EnterpriseSSO'; import { DEFAULT_REDIRECT_URL, DEFAULT_STATE, @@ -42,7 +43,7 @@ import { REGISTRATION_EXTRA_FIELDS, } from '../data/constants'; import messages from './messages'; -import processLink from '../data/utils'; +import processLink, { getTpaProvider } from '../data/utils'; class RegistrationPage extends React.Component { constructor(props, context) { @@ -101,9 +102,14 @@ class RegistrationPage extends React.Component { componentDidMount() { const params = (new URL(document.location)).searchParams; + const tpaHint = params.get('tpa_hint'); const payload = { redirect_to: params.get('next') || DEFAULT_REDIRECT_URL, }; + + if (tpaHint) { + payload.tpa_hint = tpaHint; + } this.props.getThirdPartyAuthContext(payload); this.props.fetchRegistrationForm(); } @@ -642,16 +648,13 @@ class RegistrationPage extends React.Component { return thirdPartyComponent; } - render() { - const { intl, submitState, thirdPartyAuthApiStatus } = this.props; - const { - currentProvider, finishAuthUrl, providers, secondaryProviders, - } = this.props.thirdPartyAuthContext; - - if (!this.props.formData) { - return
; - } - + renderForm(currentProvider, + providers, + secondaryProviders, + thirdPartyAuthApiStatus, + finishAuthUrl, + submitState, + intl) { if (this.state.institutionLogin) { return ( ); } + + render() { + const { intl, submitState, thirdPartyAuthApiStatus } = this.props; + const { + currentProvider, finishAuthUrl, providers, secondaryProviders, + } = this.props.thirdPartyAuthContext; + + if (!this.props.formData) { + return
; + } + + const params = (new URL(window.location.href)).searchParams; + const tpaHint = params.get('tpa_hint'); + + if (tpaHint) { + if (thirdPartyAuthApiStatus === PENDING_STATE) { + return ; + } + const provider = getTpaProvider(tpaHint, providers, secondaryProviders); + return provider ? () + : this.renderForm(currentProvider, + providers, + secondaryProviders, + thirdPartyAuthApiStatus, + finishAuthUrl, + submitState, + intl); + } + return this.renderForm(currentProvider, + providers, + secondaryProviders, + thirdPartyAuthApiStatus, + finishAuthUrl, + submitState, + intl); + } } RegistrationPage.defaultProps = { diff --git a/src/register/tests/RegistrationPage.test.jsx b/src/register/tests/RegistrationPage.test.jsx index cbfce000..d5d26e46 100644 --- a/src/register/tests/RegistrationPage.test.jsx +++ b/src/register/tests/RegistrationPage.test.jsx @@ -11,7 +11,7 @@ import CookiePolicyBanner from '@edx/frontend-component-cookie-policy-banner'; import RegistrationPage from '../RegistrationPage'; import { RenderInstitutionButton } from '../../common-components'; import RegistrationFailureMessage from '../RegistrationFailure'; -import { PENDING_STATE } from '../../data/constants'; +import { COMPLETE_STATE, PENDING_STATE } from '../../data/constants'; import { INTERNAL_SERVER_ERROR } from '../../login/data/constants'; import { fetchRegistrationForm, fetchRealtimeValidations, registerNewUser } from '../data/actions'; @@ -476,7 +476,7 @@ describe('./RegistrationPage.js', () => { }, }); delete window.location; - window.location = { href: '' }; + window.location = { href: getConfig().BASE_URL }; renderer.create(reduxWrapper()); expect(window.location.href).toBe(dasboardUrl); }); @@ -492,6 +492,8 @@ describe('./RegistrationPage.js', () => { }, }, }); + delete window.location; + window.location = { href: getConfig().BASE_URL }; const root = mount(reduxWrapper()); expect(root.text().includes('Use my institution/campus credentials')).toBe(true); }); @@ -507,6 +509,8 @@ describe('./RegistrationPage.js', () => { }, }, }); + delete window.location; + window.location = { href: getConfig().BASE_URL }; const root = mount(reduxWrapper()); root.find(RenderInstitutionButton).simulate('click', { institutionLogin: true }); expect(root.text().includes('Test University')).toBe(true); @@ -533,7 +537,7 @@ describe('./RegistrationPage.js', () => { }); delete window.location; - window.location = { href: '' }; + window.location = { href: getConfig().BASE_URL }; renderer.create(reduxWrapper()); expect(window.location.href).toBe(getConfig().LMS_BASE_URL + authCompleteUrl); @@ -569,7 +573,7 @@ describe('./RegistrationPage.js', () => { }); delete window.location; - window.location = { href: '' }; + window.location = { href: getConfig().BASE_URL }; const loginPage = mount(reduxWrapper()); @@ -589,6 +593,8 @@ describe('./RegistrationPage.js', () => { }, }); + delete window.location; + window.location = { href: getConfig().BASE_URL }; const expectedMessage = 'You\'ve successfully signed into Apple. We just need a little more information before ' + 'you start learning with openedX.'; @@ -597,39 +603,75 @@ describe('./RegistrationPage.js', () => { }); it('check cookie rendered', () => { + delete window.location; + window.location = { href: getConfig().BASE_URL }; const registerPage = mount(reduxWrapper()); expect(registerPage.find()).toBeTruthy(); }); - it('should show error message on 409 on alert and below the fields', () => { - const windowSpy = jest.spyOn(global, 'window', 'get'); - windowSpy.mockImplementation(() => ({ - scrollTo: jest.fn(), - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - })); + it('should render tpa button for tpa_hint id in primary provider', () => { + const expectedMessage = `Sign in using ${appleProvider.name}`; store = mockStore({ ...initialState, - register: { - ...initialState.register, - isSubmitted: true, - registrationError: { - email: [ - { - user_message: 'It looks like test@gmail.com belongs to an existing account. Try again with a different email address.', - }, - ], - username: [ - { - user_message: 'It looks like test belongs to an existing account. Try again with a different username.', - }, - ], + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + providers: [appleProvider], }, + thirdPartyAuthApiStatus: COMPLETE_STATE, }, }); - const tree = renderer.create(reduxWrapper()); - expect(tree.toJSON()).toMatchSnapshot(); - windowSpy.mockClear(); + delete window.location; + window.location = { href: getConfig().BASE_URL.concat(`/login?next=/dashboard&tpa_hint=${appleProvider.id}`) }; + appleProvider.iconImage = null; + + const registerPage = mount(reduxWrapper()); + expect(registerPage.find(`button#${appleProvider.id}`).find('span').text()).toEqual(expectedMessage); + }); + + it('should render regular tpa button for invalid tpa_hint value', () => { + const expectedMessage = `${appleProvider.name}`; + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + providers: [appleProvider], + }, + thirdPartyAuthApiStatus: COMPLETE_STATE, + }, + }); + + delete window.location; + window.location = { href: getConfig().BASE_URL.concat('/login?next=/dashboard&tpa_hint=invalid') }; + appleProvider.iconImage = null; + + const registerPage = mount(reduxWrapper()); + expect(registerPage.find(`button#${appleProvider.id}`).find('span').text()).toEqual(expectedMessage); + }); + + it('should render tpa button for tpa_hint id in secondary provider', () => { + const expectedMessage = `Sign in using ${secondaryProviders.name}`; + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + thirdPartyAuthContext: { + ...initialState.commonComponents.thirdPartyAuthContext, + secondaryProviders: [secondaryProviders], + }, + thirdPartyAuthApiStatus: COMPLETE_STATE, + }, + }); + + delete window.location; + window.location = { href: getConfig().BASE_URL.concat(`/login?next=/dashboard&tpa_hint=${secondaryProviders.id}`) }; + secondaryProviders.iconImage = null; + + const registerPage = mount(reduxWrapper()); + expect(registerPage.find(`button#${secondaryProviders.id}`).find('span').text()).toEqual(expectedMessage); }); }); diff --git a/src/register/tests/__snapshots__/RegistrationPage.test.jsx.snap b/src/register/tests/__snapshots__/RegistrationPage.test.jsx.snap index 31beaff2..ddb495c2 100644 --- a/src/register/tests/__snapshots__/RegistrationPage.test.jsx.snap +++ b/src/register/tests/__snapshots__/RegistrationPage.test.jsx.snap @@ -812,228 +812,3 @@ exports[`./RegistrationPage.js should match pending button state snapshot 1`] =
`; - -exports[`./RegistrationPage.js should show error message on 409 on alert and below the fields 1`] = ` -
-
-
-
-
- We couldn't create your account. -
-
    -
  • - It looks like test@gmail.com belongs to an existing account. Try again with a different email address. -
  • -
  • - It looks like test belongs to an existing account. Try again with a different username. -
  • -
-
-

- Already have an edX account? - - Sign in. - -

-
-

- Create a new account -

-
-
- - - -
-
- - - - - It looks like test belongs to an existing account. Try again with a different username. - -
-
- - - - - It looks like test@gmail.com belongs to an existing account. Try again with a different email address. - -
-
- - - -
-
- - I agree to the Your Platform Name Here - - Honor Code - - - - You must agree to the Your Platform Name Here Honor Code - -
-
- - - -
- -
-
-
-
-`;