Merge pull request #22 from edx/aehsan/VAN-94/added_saml_integration_for_logistration

Added saml integration for login and registration
This commit is contained in:
Adeel Ehsan
2020-11-24 15:33:36 +05:00
committed by GitHub
11 changed files with 420 additions and 80 deletions

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import PropTypes from 'prop-types';
import { Button } from '@edx/paragon';
import messages from './messages';
export const RenderInstitutionButton = props => {
const { onSubmitHandler, secondaryProviders, buttonTitle } = props;
if (secondaryProviders !== undefined && secondaryProviders.length > 0) {
return (
<Button
className="mb-2"
block
variant="outline-primary"
onClick={onSubmitHandler}
>
{buttonTitle}
</Button>
);
}
return <></>;
};
const InstitutionLogistration = props => {
const lmsBaseUrl = getConfig().LMS_BASE_URL;
const {
intl,
onSubmitHandler,
secondaryProviders,
headingTitle,
buttonTitle,
} = props;
return (
<>
<div className="d-flex justify-content-center institution-login-container">
<div className="d-flex flex-column" style={{ width: '450px' }}>
<p className="mt-5 ml-3 mb-4" style={{ color: '#23419f', fontSize: '20px' }}>
{headingTitle}
</p>
<div style={{ fontSize: '16px' }}>
<p
className="mb-2"
style={{ fontSize: '16px' }}
>
{intl.formatMessage(messages['logistration.institution.login.page.sub.heading'])}
</p>
<div className="mb-2 ml-2">
<ul>
{secondaryProviders.map(
provider => <li key={provider}><a href={lmsBaseUrl + provider.loginUrl}>{provider.name}</a></li>,
)}
</ul>
</div>
</div>
<div className="section-heading-line mb-4">
<h4>or</h4>
</div>
<Button
variant="outline-primary"
onClick={onSubmitHandler}
>
{buttonTitle}
</Button>
</div>
</div>
</>
);
};
const LogistrationDefaultProps = {
secondaryProviders: [],
buttonTitle: '',
};
const LogistrationProps = {
onSubmitHandler: PropTypes.func.isRequired,
secondaryProviders: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string.isRequried,
loginUrl: PropTypes.string.isRequired,
})),
buttonTitle: PropTypes.string,
};
RenderInstitutionButton.propTypes = {
...LogistrationProps,
};
RenderInstitutionButton.defaultProps = {
...LogistrationDefaultProps,
};
InstitutionLogistration.propTypes = {
...LogistrationProps,
intl: intlShape.isRequired,
headingTitle: PropTypes.string,
};
InstitutionLogistration.defaultProps = {
...LogistrationDefaultProps,
headingTitle: '',
};
export default injectIntl(InstitutionLogistration);

View File

@@ -5,8 +5,8 @@ import { faCaretDown, faCaretRight } from '@fortawesome/free-solid-svg-icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import SwitchContent from './SwitchContent';
import messages from './LoginHelpLinks.messages';
import { REGISTER_PAGE, RESET_PAGE } from '../data/constants';
import messages from './messages';
const LoginHelpLinks = (props) => {
const { intl, page } = props;

View File

@@ -1,32 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'logistration.need.help.signing.in.collapsible.menu': {
id: 'logistration.need.help.signing.in.collapsible.menu',
defaultMessage: 'Need help signing in?',
description: 'A button for collapsible need help signing in menu on login page',
},
'logistration.need.other.help.signing.in.collapsible.menu': {
id: 'logistration.need.other.help.signing.in.collapsible.menu',
defaultMessage: 'Need other help signing in?',
description: 'A button for collapsible need other help signing in menu on forgot password page',
},
'logistration.register.link': {
id: 'logistration.register.link',
defaultMessage: 'Create an account',
description: 'Register page link',
},
'logistration.forgot.password.link': {
id: 'logistration.forgot.password.link',
defaultMessage: 'Forgot my password',
description: 'Forgot password link',
},
'logistration.other.sign.in.issues': {
id: 'logistration.other.sign.in.issues',
defaultMessage: 'Other sign-in issues',
description: 'A link that redirects to sign-in issues help',
},
});
export default messages;

View File

@@ -3,6 +3,7 @@ import React from 'react';
import { Button, Input, ValidationFormGroup } from '@edx/paragon';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { forgotPasswordResultSelector } from '../forgot-password';
import ConfirmationAlert from './ConfirmationAlert';
@@ -14,7 +15,8 @@ import LoginFailureMessage from './LoginFailure';
import RedirectLogistration from './RedirectLogistration';
import SocialAuthProviders from './SocialAuthProviders';
import ThirdPartyAuthAlert from './ThirdPartyAuthAlert';
import InstitutionLogistration, { RenderInstitutionButton } from './InstitutionLogistration';
import messages from './messages';
class LoginPage extends React.Component {
constructor(props, context) {
@@ -30,6 +32,7 @@ class LoginPage extends React.Component {
emailValid: false,
passwordValid: false,
formValid: false,
institutionLogin: false,
};
}
@@ -41,6 +44,10 @@ class LoginPage extends React.Component {
this.props.getThirdPartyAuthContext(payload);
}
handleInstitutionLogin = () => {
this.setState(prevState => ({ institutionLogin: !prevState.institutionLogin }));
}
handleSubmit = (e) => {
e.preventDefault();
const params = (new URL(document.location)).searchParams;
@@ -104,8 +111,18 @@ class LoginPage extends React.Component {
}
render() {
const { intl } = this.props;
const { currentProvider, finishAuthUrl, providers } = this.props.thirdPartyAuthContext;
if (this.state.institutionLogin) {
return (
<InstitutionLogistration
onSubmitHandler={this.handleInstitutionLogin}
secondaryProviders={this.props.thirdPartyAuthContext.secondaryProviders}
headingTitle={intl.formatMessage(messages['logistration.login.institution.login.page.title'])}
buttonTitle={intl.formatMessage(messages['logistration.login.institution.login.page.back.button'])}
/>
);
}
return (
<>
<RedirectLogistration
@@ -129,10 +146,17 @@ class LoginPage extends React.Component {
First time here?<a className="ml-1" href={REGISTER_PAGE}>Create an Account.</a>
</p>
</div>
<h3 className="text-left mt-3">{intl.formatMessage(messages['logistration.login.institution.login.sign.in'])}</h3>
<RenderInstitutionButton
onSubmitHandler={this.handleInstitutionLogin}
secondaryProviders={this.props.thirdPartyAuthContext.secondaryProviders}
buttonTitle={intl.formatMessage(messages['logistration.login.institution.login.button'])}
/>
<div className="section-heading-line mb-4">
<h4>{intl.formatMessage(messages['logistration.login.institution.login.sign.in.with'])}</h4>
</div>
<form className="m-0">
<div className="form-group">
<h3 className="text-center mt-3">Sign In</h3>
<div className="d-flex flex-column align-items-start">
<ValidationFormGroup
for="email"
@@ -208,6 +232,7 @@ LoginPage.defaultProps = {
};
LoginPage.propTypes = {
intl: intlShape.isRequired,
getThirdPartyAuthContext: PropTypes.func.isRequired,
loginRequest: PropTypes.func.isRequired,
loginResult: PropTypes.shape({
@@ -246,4 +271,4 @@ export default connect(
getThirdPartyAuthContext,
loginRequest,
},
)(LoginPage);
)(injectIntl(LoginPage));

View File

@@ -7,12 +7,20 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFacebookF, faGoogle, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
import { faGraduationCap } from '@fortawesome/free-solid-svg-icons';
import { getLocale, getCountryList } from '@edx/frontend-platform/i18n';
import {
getLocale,
getCountryList,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { registerNewUser } from './data/actions';
import { registrationRequestSelector } from './data/selectors';
import { getThirdPartyAuthContext, registerNewUser } from './data/actions';
import { registrationRequestSelector, thirdPartyAuthContextSelector } from './data/selectors';
import { DEFAULT_REDIRECT_URL } from '../data/constants';
import RedirectLogistration from './RedirectLogistration';
import RegistrationFailure from './RegistrationFailure';
import InstitutionLogistration, { RenderInstitutionButton } from './InstitutionLogistration';
import messages from './messages';
class RegistrationPage extends React.Component {
constructor(props, context) {
@@ -37,9 +45,22 @@ class RegistrationPage extends React.Component {
passwordValid: false,
countryValid: false,
formValid: false,
institutionLogin: false,
};
}
componentDidMount() {
const params = (new URL(document.location)).searchParams;
const payload = {
redirect_to: params.get('next') || DEFAULT_REDIRECT_URL,
};
this.props.getThirdPartyAuthContext(payload);
}
handleInstitutionLogin = () => {
this.setState(prevState => ({ institutionLogin: !prevState.institutionLogin }));
}
handleSubmit = (e) => {
e.preventDefault();
const params = (new URL(document.location)).searchParams;
@@ -142,6 +163,16 @@ class RegistrationPage extends React.Component {
}
render() {
if (this.state.institutionLogin) {
return (
<InstitutionLogistration
onSubmitHandler={this.handleInstitutionLogin}
secondaryProviders={this.props.thirdPartyAuthContext.secondaryProviders}
headingTitle={this.props.intl.formatMessage(messages['logistration.register.institution.login.page.title'])}
buttonTitle={this.props.intl.formatMessage(messages['logistration.register.institution.login.page.back.button'])}
/>
);
}
return (
<>
<RedirectLogistration
@@ -158,7 +189,12 @@ class RegistrationPage extends React.Component {
<span className="d-block mx-auto mb-4 section-heading-line">Create an account using</span>
<button type="button" className="btn-social facebook"><FontAwesomeIcon className="mr-2" icon={faFacebookF} />Facebook</button>
<button type="button" className="btn-social google"><FontAwesomeIcon className="mr-2" icon={faGoogle} />Google</button>
<button type="button" className="btn-social microsoft"><FontAwesomeIcon className="mr-2" icon={faMicrosoft} />Microsoft</button>
<button type="button" className="btn-social microsoft mb-3"><FontAwesomeIcon className="mr-2" icon={faMicrosoft} />Microsoft</button>
<RenderInstitutionButton
onSubmitHandler={this.handleInstitutionLogin}
secondaryProviders={this.props.thirdPartyAuthContext.secondaryProviders}
buttonTitle={this.props.intl.formatMessage(messages['logistration.register.institution.login.button'])}
/>
<span className="d-block mx-auto text-center mt-4 section-heading-line">or create a new one here</span>
</div>
@@ -267,11 +303,14 @@ RegistrationPage.defaultProps = {
registrationResult: null,
registerNewUser: null,
registrationError: null,
thirdPartyAuthContext: {},
};
RegistrationPage.propTypes = {
intl: intlShape.isRequired,
registerNewUser: PropTypes.func,
getThirdPartyAuthContext: PropTypes.func.isRequired,
registrationResult: PropTypes.shape({
redirectUrl: PropTypes.string,
success: PropTypes.bool,
@@ -280,12 +319,27 @@ RegistrationPage.propTypes = {
email: PropTypes.array,
username: PropTypes.array,
}),
thirdPartyAuthContext: PropTypes.shape({
currentProvider: PropTypes.string,
providers: PropTypes.array,
secondaryProviders: PropTypes.array,
finishAuthUrl: PropTypes.string,
pipelineUserDetails: PropTypes.shape({
email: PropTypes.string,
fullname: PropTypes.string,
firstName: PropTypes.string,
lastName: PropTypes.string,
username: PropTypes.string,
}),
}),
};
const mapStateToProps = state => {
const registrationResult = registrationRequestSelector(state);
const thirdPartyAuthContext = thirdPartyAuthContextSelector(state);
return {
registrationResult,
thirdPartyAuthContext,
registrationError: state.logistration.registrationError,
};
};
@@ -293,6 +347,7 @@ const mapStateToProps = state => {
export default connect(
mapStateToProps,
{
getThirdPartyAuthContext,
registerNewUser,
},
)(RegistrationPage);
)(injectIntl(RegistrationPage));

View File

@@ -62,7 +62,6 @@ export async function getThirdPartyAuthContext(urlParams) {
.catch((e) => {
throw (e);
});
return {
thirdPartyAuthContext: camelCaseObject(data),
};

View File

@@ -0,0 +1,77 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'logistration.need.help.signing.in.collapsible.menu': {
id: 'logistration.need.help.signing.in.collapsible.menu',
defaultMessage: 'Need help signing in?',
description: 'A button for collapsible need help signing in menu on login page',
},
'logistration.need.other.help.signing.in.collapsible.menu': {
id: 'logistration.need.other.help.signing.in.collapsible.menu',
defaultMessage: 'Need other help signing in?',
description: 'A button for collapsible need other help signing in menu on forgot password page',
},
'logistration.register.link': {
id: 'logistration.register.link',
defaultMessage: 'Create an account',
description: 'Register page link',
},
'logistration.forgot.password.link': {
id: 'logistration.forgot.password.link',
defaultMessage: 'Forgot password?',
description: 'Forgot password link',
},
'logistration.other.sign.in.issues': {
id: 'logistration.other.sign.in.issues',
defaultMessage: 'Other sign-in issues',
description: 'A link that redirects to sign-in issues help',
},
'logistration.login.institution.login.button': {
id: 'logistration.login.institution.login.button',
defaultMessage: 'Use my university info',
description: 'shows institutions list',
},
'logistration.login.institution.login.page.title': {
id: 'logistration.login.institution.login.page.title',
defaultMessage: 'Sign in with Institution/Campus Credentials',
description: 'Heading of institution page',
},
'logistration.institution.login.page.sub.heading': {
id: 'logistration.institution.login.page.sub.heading',
defaultMessage: 'Choose your institution from the list below:',
description: 'Heading of the institutions list',
},
'logistration.login.institution.login.page.back.button': {
id: 'logistration.login.institution.login.page.back.button',
defaultMessage: 'Back',
description: 'return to login page',
},
'logistration.register.institution.login.button': {
id: 'logistration.register.institution.login.button',
defaultMessage: 'Use my institution/campus credentials',
description: 'shows institutions list',
},
'logistration.register.institution.login.page.title': {
id: 'logistration.register.institution.login.page.title',
defaultMessage: 'Register with Institution/Campus Credentials',
description: 'Heading of institution page',
},
'logistration.register.institution.login.page.back.button': {
id: 'logistration.register.institution.login.page.back.button',
defaultMessage: 'Create an Account',
description: 'return to login page',
},
'logistration.login.institution.login.sign.in': {
id: 'logistration.login.institution.login.sign.in',
defaultMessage: 'Sign In',
description: 'Sign In text',
},
'logistration.login.institution.login.sign.in.with': {
id: 'logistration.login.institution.login.sign.in.with',
defaultMessage: 'or sign in with',
description: 'gives hint about other sign options ',
},
});
export default messages;

View File

@@ -6,8 +6,8 @@ import configureStore from 'redux-mock-store';
import { getConfig } from '@edx/frontend-platform';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import LoginPage from '../LoginPage';
import { RenderInstitutionButton } from '../InstitutionLogistration';
const IntlLoginPage = injectIntl(LoginPage);
const mockStore = configureStore();
@@ -22,6 +22,7 @@ describe('LoginPage', () => {
currentProvider: null,
finishAuthUrl: null,
providers: [],
secondaryProviders: [],
},
},
};
@@ -29,6 +30,13 @@ describe('LoginPage', () => {
let props = {};
let store = {};
const secondaryProviders = {
id: 'saml-test',
name: 'Test University',
loginUrl: '/dummy-auth',
registerUrl: '/dummy_auth',
};
const appleProvider = {
id: 'oa2-apple-id',
name: 'Apple',
@@ -169,13 +177,6 @@ describe('LoginPage', () => {
expect(window.location.href).toBe(getConfig().LMS_BASE_URL + authCompleteUrl);
});
it('should call the componentDidMount lifecycle method', () => {
const spy = jest.spyOn(LoginPage.WrappedComponent.prototype, 'componentDidMount');
mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(spy).toHaveBeenCalled();
});
it('should redirect to social auth provider url', () => {
const loginUrl = '/auth/login/apple-id/?auth_entry=login&next=/dashboard';
store = mockStore({
@@ -219,4 +220,40 @@ describe('LoginPage', () => {
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(loginPage.find('#tpa-alert').find('span').text()).toEqual(expectedMessage);
});
it('should display institution login button', () => {
store = mockStore({
...initialState,
logistration: {
...initialState.logistration,
thirdPartyAuthContext: {
...initialState.logistration.thirdPartyAuthContext,
secondaryProviders: [secondaryProviders],
},
},
});
const root = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(root.text().includes('Use my university info')).toBe(true);
});
it('should not display institution login button', () => {
const root = mount(reduxWrapper(<IntlLoginPage {...props} />));
expect(root.text().includes('Use my university info')).toBe(false);
});
it('should display institution login page', () => {
store = mockStore({
...initialState,
logistration: {
...initialState.logistration,
thirdPartyAuthContext: {
...initialState.logistration.thirdPartyAuthContext,
secondaryProviders: [secondaryProviders],
},
},
});
const loginPage = mount(reduxWrapper(<IntlLoginPage {...props} />));
loginPage.find(RenderInstitutionButton).simulate('click', { institutionLogin: true });
expect(loginPage.text().includes('Test University')).toBe(true);
});
});

View File

@@ -2,9 +2,11 @@ import React from 'react';
import { Provider } from 'react-redux';
import renderer from 'react-test-renderer';
import configureStore from 'redux-mock-store';
import { mount } from 'enzyme';
import { IntlProvider, injectIntl, configure } from '@edx/frontend-platform/i18n';
import RegistrationPage from '../RegistrationPage';
import { RenderInstitutionButton } from '../InstitutionLogistration';
const IntlRegistrationPage = injectIntl(RegistrationPage);
const mockStore = configureStore();
@@ -14,12 +16,20 @@ describe('./RegistrationPage.js', () => {
const initialState = {
logistration: {
registrationResult: { success: false, redirectUrl: '' },
thirdPartyAuthContext: { secondaryProviders: [] },
},
};
let props = {};
let store = {};
const secondaryProviders = {
id: 'saml-test',
name: 'Test University',
loginUrl: '/dummy-auth',
registerUrl: '/dummy_auth',
};
const reduxWrapper = children => (
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
@@ -59,7 +69,7 @@ describe('./RegistrationPage.js', () => {
store = mockStore({
...store,
logistration: {
...store.logistration,
...initialState.logistration,
registrationResult: {
success: true,
redirectUrl: dasboardUrl,
@@ -72,6 +82,37 @@ describe('./RegistrationPage.js', () => {
expect(window.location.href).toBe(dasboardUrl);
});
it('should display institution register button', () => {
store = mockStore({
...initialState,
logistration: {
...initialState.logistration,
thirdPartyAuthContext: {
...initialState.logistration.thirdPartyAuthContext,
secondaryProviders: [secondaryProviders],
},
},
});
const root = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
expect(root.text().includes('Use my institution/campus credentials')).toBe(true);
});
it('should not display institution register button', () => {
store = mockStore({
...initialState,
logistration: {
...initialState.logistration,
thirdPartyAuthContext: {
...initialState.logistration.thirdPartyAuthContext,
secondaryProviders: [secondaryProviders],
},
},
});
const root = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
root.find(RenderInstitutionButton).simulate('click', { institutionLogin: true });
expect(root.text().includes('Test University')).toBe(true);
});
it('should show error message on 409', () => {
const windowSpy = jest.spyOn(global, 'window', 'get');
windowSpy.mockImplementation(() => ({

View File

@@ -25,17 +25,24 @@ exports[`LoginPage should match TPA provider snapshot 1`] = `
</a>
</p>
</div>
<h3
className="text-left mt-3"
>
Sign In
</h3>
<div
className="section-heading-line mb-4"
>
<h4>
or sign in with
</h4>
</div>
<form
className="m-0"
>
<div
className="form-group"
>
<h3
className="text-center mt-3"
>
Sign In
</h3>
<div
className="d-flex flex-column align-items-start"
>
@@ -223,17 +230,24 @@ exports[`LoginPage should match default section snapshot 1`] = `
</a>
</p>
</div>
<h3
className="text-left mt-3"
>
Sign In
</h3>
<div
className="section-heading-line mb-4"
>
<h4>
or sign in with
</h4>
</div>
<form
className="m-0"
>
<div
className="form-group"
>
<h3
className="text-center mt-3"
>
Sign In
</h3>
<div
className="d-flex flex-column align-items-start"
>
@@ -387,17 +401,24 @@ exports[`LoginPage should match forget password alert message snapshot 1`] = `
</a>
</p>
</div>
<h3
className="text-left mt-3"
>
Sign In
</h3>
<div
className="section-heading-line mb-4"
>
<h4>
or sign in with
</h4>
</div>
<form
className="m-0"
>
<div
className="form-group"
>
<h3
className="text-center mt-3"
>
Sign In
</h3>
<div
className="d-flex flex-column align-items-start"
>
@@ -574,17 +595,24 @@ exports[`LoginPage should show error message on 400 1`] = `
</a>
</p>
</div>
<h3
className="text-left mt-3"
>
Sign In
</h3>
<div
className="section-heading-line mb-4"
>
<h4>
or sign in with
</h4>
</div>
<form
className="m-0"
>
<div
className="form-group"
>
<h3
className="text-center mt-3"
>
Sign In
</h3>
<div
className="d-flex flex-column align-items-start"
>
@@ -769,17 +797,24 @@ exports[`LoginPage should show error message on 400 on receiving link 1`] = `
</a>
</p>
</div>
<h3
className="text-left mt-3"
>
Sign In
</h3>
<div
className="section-heading-line mb-4"
>
<h4>
or sign in with
</h4>
</div>
<form
className="m-0"
>
<div
className="form-group"
>
<h3
className="text-center mt-3"
>
Sign In
</h3>
<div
className="d-flex flex-column align-items-start"
>

View File

@@ -90,7 +90,7 @@ exports[`./RegistrationPage.js should match default section snapshot 1`] = `
Google
</button>
<button
className="btn-social microsoft"
className="btn-social microsoft mb-3"
type="button"
>
<svg
@@ -1678,7 +1678,7 @@ exports[`./RegistrationPage.js should show error message on 409 1`] = `
Google
</button>
<button
className="btn-social microsoft"
className="btn-social microsoft mb-3"
type="button"
>
<svg