diff --git a/src/base-container/components/welcome-page-layout/LargeLayout.jsx b/src/base-container/components/welcome-page-layout/LargeLayout.jsx index 506de944dc..dfa978de62 100644 --- a/src/base-container/components/welcome-page-layout/LargeLayout.jsx +++ b/src/base-container/components/welcome-page-layout/LargeLayout.jsx @@ -1,38 +1,92 @@ import React from 'react'; +import { useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Hyperlink, Image } from '@openedx/paragon'; -import PropTypes from 'prop-types'; +import classNames from 'classnames'; import messages from './messages'; -const LargeLayout = ({ fullName }) => { +const LargeLayout = () => { const { formatMessage } = useIntl(); + const enterpriseBranding = useSelector( + state => state.commonComponents?.thirdPartyAuthContext?.enterpriseBranding, + ); + + const enterpriseLogoUrl = enterpriseBranding?.enterpriseLogoUrl || null; + const enterpriseName = enterpriseBranding?.enterpriseName || null; + + const enterpriseWelcomeHtml = enterpriseBranding?.enterpriseBrandedWelcomeString + || enterpriseBranding?.platformWelcomeString + || ''; + + const siteName = getConfig().SITE_NAME; + const baseLogoSrc = getConfig().LOGO_WHITE_URL || getConfig().LOGO_URL; + return (
-
+
+ {/* base edX logo at very top-left */} - {getConfig().SITE_NAME} + {siteName} -
-
-
-

- {formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })} -

-

- {formatMessage(messages['complete.your.profile.1'])} -
- {formatMessage(messages['complete.your.profile.2'])} + + {/* main hero content block, aligned like Figma */} +
+ {/* row: [enterprise logo] [yellow slash] [Start learning with edX] */} +
+ {enterpriseLogoUrl && ( +
+ {enterpriseName +
+ )} + + + +
+
+ {formatMessage(messages['start.learning'])}
-

+
+ {formatMessage(messages['with.edx'])} +
+
+ + {/* enterprise-specific message aligned under heading */} + {enterpriseWelcomeHtml && ( +
+ )}
-
- + + {/* keep existing right decorative triangle */} +
+ @@ -42,8 +96,4 @@ const LargeLayout = ({ fullName }) => { ); }; -LargeLayout.propTypes = { - fullName: PropTypes.string.isRequired, -}; - export default LargeLayout; diff --git a/src/base-container/components/welcome-page-layout/MediumLayout.jsx b/src/base-container/components/welcome-page-layout/MediumLayout.jsx index 7de8ce3524..b5623f35cf 100644 --- a/src/base-container/components/welcome-page-layout/MediumLayout.jsx +++ b/src/base-container/components/welcome-page-layout/MediumLayout.jsx @@ -1,40 +1,75 @@ import React from 'react'; +import { useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Hyperlink, Image } from '@openedx/paragon'; -import PropTypes from 'prop-types'; import messages from './messages'; -const MediumLayout = ({ fullName }) => { +const MediumLayout = () => { const { formatMessage } = useIntl(); + const enterpriseBranding = useSelector( + state => state.commonComponents?.thirdPartyAuthContext?.enterpriseBranding, + ); + + const enterpriseLogoUrl = enterpriseBranding?.enterpriseLogoUrl || null; + const enterpriseName = enterpriseBranding?.enterpriseName || null; + const enterpriseWelcomeHtml = enterpriseBranding?.enterpriseBrandedWelcomeString + || enterpriseBranding?.platformWelcomeString + || ''; + + const siteName = getConfig().SITE_NAME; + const baseLogoSrc = getConfig().LOGO_WHITE_URL || getConfig().LOGO_URL; + return ( <>
-
+
- {getConfig().SITE_NAME} + {siteName} -
-
-
-

- {formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })} -

-

- {formatMessage(messages['complete.your.profile.1'])} -
- {formatMessage(messages['complete.your.profile.2'])} + +
+
+ {enterpriseLogoUrl && ( +
+ {enterpriseName +
+ )} + + + +
+
+ {formatMessage(messages['start.learning'])} +
+
+ {formatMessage(messages['with.site.name'], { siteName })}
-

+
+ + {enterpriseWelcomeHtml && ( +
+ )}
- + @@ -45,8 +80,4 @@ const MediumLayout = ({ fullName }) => { ); }; -MediumLayout.propTypes = { - fullName: PropTypes.string.isRequired, -}; - export default MediumLayout; diff --git a/src/base-container/components/welcome-page-layout/SmallLayout.jsx b/src/base-container/components/welcome-page-layout/SmallLayout.jsx index c1a21d3e20..ec67825943 100644 --- a/src/base-container/components/welcome-page-layout/SmallLayout.jsx +++ b/src/base-container/components/welcome-page-layout/SmallLayout.jsx @@ -1,41 +1,72 @@ import React from 'react'; +import { useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Hyperlink, Image } from '@openedx/paragon'; -import PropTypes from 'prop-types'; import messages from './messages'; -const SmallLayout = ({ fullName }) => { +const SmallLayout = () => { const { formatMessage } = useIntl(); + const enterpriseBranding = useSelector( + state => state.commonComponents?.thirdPartyAuthContext?.enterpriseBranding, + ); + + const enterpriseLogoUrl = enterpriseBranding?.enterpriseLogoUrl || null; + const enterpriseName = enterpriseBranding?.enterpriseName || null; + const enterpriseWelcomeHtml = enterpriseBranding?.enterpriseBrandedWelcomeString + || enterpriseBranding?.platformWelcomeString + || ''; + + const siteName = getConfig().SITE_NAME; + const baseLogoSrc = getConfig().LOGO_WHITE_URL || getConfig().LOGO_URL; + return ( -
+
- {getConfig().SITE_NAME} + {siteName} -
-
-
-

- {formatMessage(messages['welcome.to.platform'], { siteName: getConfig().SITE_NAME, fullName })} -

-

- {formatMessage(messages['complete.your.profile.1'])} -
- {formatMessage(messages['complete.your.profile.2'])} + +
+
+ {enterpriseLogoUrl && ( +
+ {enterpriseName +
+ )} + + + +
+
+ {formatMessage(messages['start.learning'])} +
+
+ {formatMessage(messages['with.site.name'], { siteName })}
-

+
+ + {enterpriseWelcomeHtml && ( +
+ )}
); }; -SmallLayout.propTypes = { - fullName: PropTypes.string.isRequired, -}; - export default SmallLayout; diff --git a/src/base-container/components/welcome-page-layout/messages.js b/src/base-container/components/welcome-page-layout/messages.js index de36eb17d7..e2e08b9e8b 100644 --- a/src/base-container/components/welcome-page-layout/messages.js +++ b/src/base-container/components/welcome-page-layout/messages.js @@ -6,6 +6,20 @@ const messages = defineMessages({ defaultMessage: 'Welcome to {siteName}, {fullName}!', description: 'Welcome message that appears on progressive profile page', }, + 'start.learning': { + id: 'start.learning', + defaultMessage: 'Start learning', + description: 'Header text for logistration MFE pages', + }, + 'with.site.name': { + id: 'with.site.name', + defaultMessage: 'with {siteName}', + description: 'Header text with site name for logistration MFE pages', + }, + 'with.edx': { + id: 'with.edx', + defaultMessage: ' with edX', + }, 'complete.your.profile.1': { id: 'complete.your.profile.1', defaultMessage: 'Complete', diff --git a/src/common-components/data/reducers.js b/src/common-components/data/reducers.js index b51f989f3c..a22e6717d5 100644 --- a/src/common-components/data/reducers.js +++ b/src/common-components/data/reducers.js @@ -18,6 +18,7 @@ export const defaultState = { pipelineUserDetails: null, errorMessage: null, welcomePageRedirectUrl: null, + enterpriseBranding: null, }, }; @@ -28,16 +29,24 @@ const reducer = (state = defaultState, action = {}) => { ...state, thirdPartyAuthApiStatus: PENDING_STATE, }; - case THIRD_PARTY_AUTH_CONTEXT.SUCCESS: { - return { - ...state, - fieldDescriptions: action.payload.fieldDescriptions?.fields, - optionalFields: action.payload.optionalFields, - thirdPartyAuthContext: action.payload.thirdPartyAuthContext, - thirdPartyAuthApiStatus: COMPLETE_STATE, - countriesCodesList: action.payload.countriesCodesList, - }; - } + case THIRD_PARTY_AUTH_CONTEXT.SUCCESS: { + return { + ...state, + fieldDescriptions: action.payload.fieldDescriptions?.fields, + optionalFields: action.payload.optionalFields, + thirdPartyAuthContext: { + ...action.payload.thirdPartyAuthContext, + enterpriseBranding: action.payload.thirdPartyAuthContext.enterpriseBranding + ? { + ...action.payload.thirdPartyAuthContext.enterpriseBranding, + enterpriseSlug: action.payload.thirdPartyAuthContext.enterpriseBranding.enterpriseSlug || null, + } + : null, + }, + thirdPartyAuthApiStatus: COMPLETE_STATE, + }; + } + case THIRD_PARTY_AUTH_CONTEXT.FAILURE: return { ...state, diff --git a/src/common-components/data/tests/reducer.test.js b/src/common-components/data/tests/reducer.test.js index 9879820881..93df6f125f 100644 --- a/src/common-components/data/tests/reducer.test.js +++ b/src/common-components/data/tests/reducer.test.js @@ -16,6 +16,7 @@ describe('common components reducer', () => { secondaryProviders: [], pipelineUserDetails: null, errorMessage: null, + enterpriseBranding: null, }, }; const fieldDescriptions = { diff --git a/src/data/constants.js b/src/data/constants.js index 5adf438218..d389c3e21b 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -9,6 +9,7 @@ export const RECOMMENDATIONS = '/recommendations'; export const PASSWORD_RESET_CONFIRM = '/password_reset_confirm/:token/'; export const PAGE_NOT_FOUND = '/notfound'; export const ENTERPRISE_LOGIN_URL = '/enterprise/login'; +export const APP_NAME = 'authn'; // Constants export const SUPPORTED_ICON_CLASSES = ['apple', 'facebook', 'google', 'microsoft']; @@ -35,6 +36,5 @@ export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+ // Query string parameters that can be passed to LMS to manage // things like auto-enrollment upon login and registration. -export const AUTH_PARAMS = ['course_id', 'enrollment_action', 'course_mode', 'email_opt_in', 'purchase_workflow', 'next', 'register_for_free', 'track', 'is_account_recovery', 'variant', 'host', 'cta']; +export const AUTH_PARAMS = ['course_id', 'enrollment_action', 'course_mode', 'email_opt_in', 'purchase_workflow', 'next', 'register_for_free', 'track', 'is_account_recovery', 'variant', 'host', 'cta', 'enterprise_customer']; export const REDIRECT = 'redirect'; -export const APP_NAME = 'authn_mfe'; diff --git a/src/data/utils/dataUtils.js b/src/data/utils/dataUtils.js index 927f0c028e..3481133ab0 100644 --- a/src/data/utils/dataUtils.js +++ b/src/data/utils/dataUtils.js @@ -81,9 +81,50 @@ export const isHostAvailableInQueryParams = () => { const queryParams = getAllPossibleQueryParams(); return 'host' in queryParams; }; +/** + * Constructs the enterprise redirect URL with slug + * @param {string} enterpriseSlug - Enterprise customer slug + * @param {string} baseUrl - Base URL for enterprise portal (default: enterprise.edx.org) + * @returns {string|null} - Formatted enterprise portal URL or null if slug is missing + */ +export const getEnterpriseRedirectUrl = (enterpriseSlug, baseUrl = 'https://enterprise.edx.org') => { + if (!enterpriseSlug) { + return null; + } + return `${baseUrl}/${enterpriseSlug}`; +}; + +/** + * Updates query params to include enterprise slug in next parameter if enterprise branding is present + * @param {object} queryParams - Current query parameters + * @param {string} enterpriseSlug - Enterprise customer slug + * @returns {object} - Updated query parameters with next URL including slug + */ +export const addEnterpriseSlugToNext = (queryParams, enterpriseSlug) => { + if (!enterpriseSlug) { + return queryParams; + } + + const enterpriseUrl = getEnterpriseRedirectUrl(enterpriseSlug); + const updatedParams = { ...queryParams }; + + if (!updatedParams.next && enterpriseUrl) { + updatedParams.next = enterpriseUrl; + } + + return updatedParams; +}; -export const redirectWithDelay = (redirectUrl) => { - setTimeout(() => { - window.location.href = redirectUrl; - }, 1000); +export const redirectWithDelay = (url, delay = 1000) => { + if (!url) { + return; + } + + window.setTimeout(() => { + if (window.location && typeof window.location.assign === 'function') { + window.location.assign(url); + } else { + window.location.href = url; + } + }, delay); }; diff --git a/src/login/LoginPage.jsx b/src/login/LoginPage.jsx index 30e0abe889..8791f5170a 100644 --- a/src/login/LoginPage.jsx +++ b/src/login/LoginPage.jsx @@ -1,29 +1,17 @@ -import { useEffect, useMemo, useState } from 'react'; -import { connect, useDispatch } from 'react-redux'; +import { + useCallback, useEffect, useMemo, useState, +} from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; +import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { - Form, StatefulButton, -} from '@openedx/paragon'; +import { Form, StatefulButton } from '@openedx/paragon'; import PropTypes from 'prop-types'; import { Helmet } from 'react-helmet'; import Skeleton from 'react-loading-skeleton'; import { Link } from 'react-router-dom'; -import AccountActivationMessage from './AccountActivationMessage'; -import { - backupLoginFormBegin, - dismissPasswordResetBanner, - loginRequest, -} from './data/actions'; -import { INVALID_FORM, TPA_AUTHENTICATION_FAILURE } from './data/constants'; -import LoginFailureMessage from './LoginFailure'; -import messages from './messages'; -import { - ELEMENT_NAME, ELEMENT_TEXT, ELEMENT_TYPES, PAGE_TYPES, -} from '../cohesion/constants'; -import { setCohesionEventStates } from '../cohesion/data/actions'; import { FormGroup, InstitutionLogistration, @@ -31,11 +19,12 @@ import { RedirectLogistration, ThirdPartyAuthAlert, } from '../common-components'; +import AccountActivationMessage from './AccountActivationMessage'; import { getThirdPartyAuthContext } from '../common-components/data/actions'; import { thirdPartyAuthContextSelector } from '../common-components/data/selectors'; import EnterpriseSSO from '../common-components/EnterpriseSSO'; import ThirdPartyAuth from '../common-components/ThirdPartyAuth'; -import { DEFAULT_STATE, PENDING_STATE, RESET_PAGE } from '../data/constants'; +import { APP_NAME, PENDING_STATE, RESET_PAGE } from '../data/constants'; import { getActivationStatus, getAllPossibleQueryParams, @@ -43,65 +32,85 @@ import { getTpaProvider, updatePathWithQueryParams, } from '../data/utils'; -import { removeCookie } from '../data/utils/cookies'; import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess'; -import { - trackForgotPasswordLinkClick, trackLoginPageViewed, trackLoginSuccess, -} from '../tracking/trackers/login'; +import { backupLoginFormBegin, dismissPasswordResetBanner, loginRequest } from './data/actions'; +import { INVALID_FORM, TPA_AUTHENTICATION_FAILURE } from './data/constants'; +import LoginFailureMessage from './LoginFailure'; +import messages from './messages'; + +const DEFAULT_LOGIN_FORM = { + formFields: { emailOrUsername: '', password: '' }, + errors: { emailOrUsername: '', password: '' }, +}; -const LoginPage = (props) => { +const LoginPage = ({ + institutionLogin, + handleInstitutionLogin, +}) => { + const dispatch = useDispatch(); + const backupFormState = useCallback((data) => dispatch(backupLoginFormBegin(data)), [dispatch]); + const getTPADataFromBackend = useCallback( + (urlParams) => dispatch(getThirdPartyAuthContext(urlParams)), + [dispatch], + ); const { backedUpFormData, loginErrorCode, loginErrorContext, loginResult, shouldBackupState, - thirdPartyAuthContext: { - providers, - currentProvider, - secondaryProviders, - finishAuthUrl, - platformName, - errorMessage: thirdPartyErrorMessage, - }, - thirdPartyAuthApiStatus, - institutionLogin, showResetPasswordSuccessBanner, submitState, - // Actions - backupFormState, - handleInstitutionLogin, - getTPADataFromBackend, - } = props; + thirdPartyAuthContext, + thirdPartyAuthApiStatus, + } = useSelector((state) => ({ + backedUpFormData: state.login.loginFormData, + loginErrorCode: state.login.loginErrorCode, + loginErrorContext: state.login.loginErrorContext, + loginResult: state.login.loginResult, + shouldBackupState: state.login.shouldBackupState, + showResetPasswordSuccessBanner: state.login.showResetPasswordSuccessBanner, + submitState: state.login.submitState, + thirdPartyAuthContext: thirdPartyAuthContextSelector(state), + thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus, + })); + const { + providers, + currentProvider, + secondaryProviders, + finishAuthUrl, + platformName, + errorMessage: thirdPartyErrorMessage, + } = thirdPartyAuthContext; const { formatMessage } = useIntl(); - const dispatch = useDispatch(); const activationMsgType = getActivationStatus(); const queryParams = useMemo(() => getAllPossibleQueryParams(), []); - const [formFields, setFormFields] = useState({ ...backedUpFormData.formFields }); - const [errorCode, setErrorCode] = useState({ type: '', count: 0, context: {} }); - const [errors, setErrors] = useState({ ...backedUpFormData.errors }); + const [errorCode, setErrorCode] = useState({ + type: '', + count: 0, + context: {}, + }); + const backedUp = backedUpFormData || DEFAULT_LOGIN_FORM; + + // 2) Normalize base URL for redirects (prevents undefined href in tests) + const LMS_BASE = getConfig().LMS_BASE_URL || getConfig().BASE_URL || ''; + + const [formFields, setFormFields] = useState({ ...backedUp.formFields }); + const [errors, setErrors] = useState({ ...backedUp.errors }); const tpaHint = getTpaHint(); useEffect(() => { - trackLoginPageViewed(); + sendPageEvent('login_and_registration', 'login', { app_name: APP_NAME }); }, []); - useEffect(() => { - if (loginResult.success) { - trackLoginSuccess(); - // Remove this cookie that was set to capture marketingEmailsOptIn for the onboarding component - removeCookie('ssoPipelineRedirectionDone'); - } - }, [loginResult]); - useEffect(() => { const payload = { ...queryParams }; if (tpaHint) { payload.tpa_hint = tpaHint; } getTPADataFromBackend(payload); - }, [getTPADataFromBackend, queryParams, tpaHint]); + }, [queryParams, tpaHint, getTPADataFromBackend]); /** * Backup the login form in redux when login page is toggled. */ @@ -112,7 +121,7 @@ const LoginPage = (props) => { errors: { ...errors }, }); } - }, [shouldBackupState, formFields, errors, backupFormState]); + }, [backupFormState, shouldBackupState, formFields, errors]); useEffect(() => { if (loginErrorCode) { @@ -137,7 +146,10 @@ const LoginPage = (props) => { }, [thirdPartyErrorMessage]); const validateFormFields = (payload) => { - const { emailOrUsername, password } = payload; + const { + emailOrUsername, + password, + } = payload; const fieldErrors = { ...errors }; if (emailOrUsername === '') { @@ -154,24 +166,19 @@ const LoginPage = (props) => { const handleSubmit = (event) => { event.preventDefault(); - const eventData = { - pageType: PAGE_TYPES.SIGN_IN, - elementType: ELEMENT_TYPES.BUTTON, - webElementText: ELEMENT_TEXT.SIGN_IN, - webElementName: ELEMENT_NAME.SIGN_IN, - }; - - dispatch(setCohesionEventStates(eventData)); - if (showResetPasswordSuccessBanner) { - props.dismissPasswordResetBanner(); + dispatch(dismissPasswordResetBanner()); } const formData = { ...formFields }; const validationErrors = validateFormFields(formData); if (validationErrors.emailOrUsername || validationErrors.password) { setErrors({ ...validationErrors }); - setErrorCode(prevState => ({ type: INVALID_FORM, count: prevState.count + 1, context: {} })); + setErrorCode(prevState => ({ + type: INVALID_FORM, + count: prevState.count + 1, + context: {}, + })); return; } @@ -181,20 +188,35 @@ const LoginPage = (props) => { password: formData.password, ...queryParams, }; - props.loginRequest(payload); + dispatch(loginRequest(payload)); }; const handleOnChange = (event) => { - const { name, value } = event.target; - setFormFields(prevState => ({ ...prevState, [name]: value })); + const { + name, + value, + } = event.target; + setFormFields(prevState => ({ + ...prevState, + [name]: value, + })); }; const handleOnFocus = (event) => { const { name } = event.target; - setErrors(prevErrors => ({ ...prevErrors, [name]: '' })); + setErrors(prevErrors => ({ + ...prevErrors, + [name]: '', + })); + }; + const trackForgotPasswordLinkClick = () => { + sendTrackEvent('edx.bi.password-reset_form.toggled', { category: 'user-engagement', app_name: APP_NAME }); }; - const { provider, skipHintedLogin } = getTpaProvider(tpaHint, providers, secondaryProviders); + const { + provider, + skipHintedLogin, + } = getTpaProvider(tpaHint, providers, secondaryProviders); if (tpaHint) { if (thirdPartyAuthApiStatus === PENDING_STATE) { @@ -202,7 +224,7 @@ const LoginPage = (props) => { } if (skipHintedLogin) { - window.location.href = getConfig().LMS_BASE_URL + provider.loginUrl; + window.location.href = `${LMS_BASE}${provider.loginUrl}`; return null; } @@ -228,7 +250,6 @@ const LoginPage = (props) => { success={loginResult.success} redirectUrl={loginResult.redirectUrl} finishAuthUrl={finishAuthUrl} - currentProvider={currentProvider} />
{ ); }; -const mapStateToProps = state => { - const loginPageState = state.login; - return { - backedUpFormData: loginPageState.loginFormData, - loginErrorCode: loginPageState.loginErrorCode, - loginErrorContext: loginPageState.loginErrorContext, - loginResult: loginPageState.loginResult, - shouldBackupState: loginPageState.shouldBackupState, - showResetPasswordSuccessBanner: loginPageState.showResetPasswordSuccessBanner, - submitState: loginPageState.submitState, - thirdPartyAuthContext: thirdPartyAuthContextSelector(state), - thirdPartyAuthApiStatus: state.commonComponents.thirdPartyAuthApiStatus, - }; -}; - LoginPage.propTypes = { - backedUpFormData: PropTypes.shape({ - formFields: PropTypes.shape({}), - errors: PropTypes.shape({}), - }), - loginErrorCode: PropTypes.string, - loginErrorContext: PropTypes.shape({ - email: PropTypes.string, - redirectUrl: PropTypes.string, - context: PropTypes.shape({}), - }), - loginResult: PropTypes.shape({ - redirectUrl: PropTypes.string, - success: PropTypes.bool, - }), - shouldBackupState: PropTypes.bool, - showResetPasswordSuccessBanner: PropTypes.bool, - submitState: PropTypes.string, - thirdPartyAuthApiStatus: PropTypes.string, institutionLogin: PropTypes.bool.isRequired, - thirdPartyAuthContext: PropTypes.shape({ - currentProvider: PropTypes.string, - errorMessage: PropTypes.string, - platformName: PropTypes.string, - providers: PropTypes.arrayOf(PropTypes.shape({})), - secondaryProviders: PropTypes.arrayOf(PropTypes.shape({})), - finishAuthUrl: PropTypes.string, - }), - // Actions - backupFormState: PropTypes.func.isRequired, - dismissPasswordResetBanner: PropTypes.func.isRequired, - loginRequest: PropTypes.func.isRequired, - getTPADataFromBackend: PropTypes.func.isRequired, handleInstitutionLogin: PropTypes.func.isRequired, }; -LoginPage.defaultProps = { - backedUpFormData: { - formFields: { - emailOrUsername: '', password: '', - }, - errors: { - emailOrUsername: '', password: '', - }, - }, - loginErrorCode: null, - loginErrorContext: {}, - loginResult: {}, - shouldBackupState: false, - showResetPasswordSuccessBanner: false, - submitState: DEFAULT_STATE, - thirdPartyAuthApiStatus: PENDING_STATE, - thirdPartyAuthContext: { - currentProvider: null, - errorMessage: null, - finishAuthUrl: null, - providers: [], - secondaryProviders: [], - }, -}; - -export default connect( - mapStateToProps, - { - backupFormState: backupLoginFormBegin, - dismissPasswordResetBanner, - loginRequest, - getTPADataFromBackend: getThirdPartyAuthContext, - }, -)(LoginPage); +export default LoginPage; diff --git a/src/logistration/Logistration.jsx b/src/logistration/Logistration.jsx index fb15f2dad8..06e4572bef 100644 --- a/src/logistration/Logistration.jsx +++ b/src/logistration/Logistration.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; @@ -17,6 +17,7 @@ import { Navigate, useNavigate } from 'react-router-dom'; import BaseContainer from '../base-container'; import { clearThirdPartyAuthContextErrorMessage } from '../common-components/data/actions'; import { + thirdPartyAuthContextSelector, tpaProvidersSelector, } from '../common-components/data/selectors'; import messages from '../common-components/messages'; @@ -24,28 +25,43 @@ import { APP_NAME, LOGIN_PAGE, REGISTER_PAGE } from '../data/constants'; import { getTpaHint, getTpaProvider, updatePathWithQueryParams, } from '../data/utils'; -import { LoginPage } from '../login'; import { backupLoginForm } from '../login/data/actions'; +import LoginComponentSlot from '../plugin-slots/MainAppSlot/index'; import { RegistrationPage } from '../register'; import { backupRegistrationForm } from '../register/data/actions'; -const Logistration = (props) => { - const { selectedPage, tpaProviders } = props; +const Logistration = ({ + selectedPage, +}) => { const tpaHint = getTpaHint(); + const tpaProviders = useSelector(tpaProvidersSelector); + const dispatch = useDispatch(); const { - providers, secondaryProviders, + providers, + secondaryProviders, } = tpaProviders; + const thirdPartyAuthContext = useSelector(thirdPartyAuthContextSelector); const { formatMessage } = useIntl(); const [institutionLogin, setInstitutionLogin] = useState(false); const [key, setKey] = useState(''); const navigate = useNavigate(); const disablePublicAccountCreation = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false; const hideRegistrationLink = getConfig().SHOW_REGISTRATION_LINKS === false; + const enterpriseBranding = useSelector( + state => state.commonComponents?.thirdPartyAuthContext?.enterpriseBranding, + ); + + const fullName = thirdPartyAuthContext?.pipelineUserDetails?.full_name + || thirdPartyAuthContext?.pipelineUserDetails?.name + || null; + // Show welcome banner if enterprise branding is available + const showWelcomeBanner = !!enterpriseBranding; useEffect(() => { const authService = getAuthService(); if (authService) { - authService.getCsrfTokenService().getCsrfToken(getConfig().LMS_BASE_URL); + authService.getCsrfTokenService() + .getCsrfToken(getConfig().LMS_BASE_URL); } }); @@ -70,13 +86,12 @@ const Logistration = (props) => { if (tabKey === currentTab) { return; } - sendTrackEvent(`edx.bi.${tabKey.replace('/', '')}_form.toggled`, { category: 'user-engagement', app_name: APP_NAME }); - props.clearThirdPartyAuthContextErrorMessage(); + dispatch(clearThirdPartyAuthContextErrorMessage()); if (tabKey === LOGIN_PAGE) { - props.backupRegistrationForm(); + dispatch(backupRegistrationForm()); } else if (tabKey === REGISTER_PAGE) { - props.backupLoginForm(); + dispatch(backupLoginForm()); } setKey(tabKey); }; @@ -98,8 +113,11 @@ const Logistration = (props) => { }; return ( - -
+ +
{disablePublicAccountCreation ? ( <> @@ -112,7 +130,10 @@ const Logistration = (props) => { {!institutionLogin && (

{formatMessage(messages['logistration.sign.in'])}

)} - +
) @@ -125,12 +146,16 @@ const Logistration = (props) => { ) : (!isValidTpaHint() && !hideRegistrationLink && ( - handleOnSelect(tabKey, selectedPage)}> + handleOnSelect(tabKey, selectedPage)} + > ))} - { key && ( + {key && ( )}
@@ -140,7 +165,12 @@ const Logistration = (props) => { )} {selectedPage === LOGIN_PAGE - ? + ? ( + + ) : ( { Logistration.propTypes = { selectedPage: PropTypes.string, - backupLoginForm: PropTypes.func.isRequired, - backupRegistrationForm: PropTypes.func.isRequired, - clearThirdPartyAuthContextErrorMessage: PropTypes.func.isRequired, - tpaProviders: PropTypes.shape({ - providers: PropTypes.arrayOf(PropTypes.shape({})), - secondaryProviders: PropTypes.arrayOf(PropTypes.shape({})), - }), -}; - -Logistration.defaultProps = { - tpaProviders: { - providers: [], - secondaryProviders: [], - }, }; Logistration.defaultProps = { selectedPage: REGISTER_PAGE, }; -const mapStateToProps = state => ({ - tpaProviders: tpaProvidersSelector(state), -}); - -export default connect( - mapStateToProps, - { - backupLoginForm, - backupRegistrationForm, - clearThirdPartyAuthContextErrorMessage, - }, -)(Logistration); +export default Logistration; diff --git a/src/logistration/Logistration.test.jsx b/src/logistration/Logistration.test.jsx index 384e89d089..116858012c 100644 --- a/src/logistration/Logistration.test.jsx +++ b/src/logistration/Logistration.test.jsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Provider } from 'react-redux'; import { getConfig, mergeConfig } from '@edx/frontend-platform'; @@ -24,6 +25,13 @@ jest.mock('@edx/frontend-platform/analytics', () => ({ sendTrackEvent: jest.fn(), })); jest.mock('@edx/frontend-platform/auth'); +jest.mock('@openedx/frontend-plugin-framework', () => ({ + PluginSlot: ({ children }) => children, +})); +jest.mock('../plugin-slots/MainAppSlot/index', () => { + const mockLoginPage = jest.requireActual('../login/LoginPage').default; + return (props) => mockLoginPage(props); +}); jest.mock('../register/data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); const mockStore = configureStore(); @@ -147,8 +155,8 @@ describe('Logistration', () => { it('should render only login page when public account creation is disabled', () => { mergeConfig({ ALLOW_PUBLIC_ACCOUNT_CREATION: false, - DISABLE_ENTERPRISE_LOGIN: 'true', - SHOW_REGISTRATION_LINKS: 'true', + DISABLE_ENTERPRISE_LOGIN: true, + SHOW_REGISTRATION_LINKS: true, }); store = mockStore({ @@ -171,14 +179,16 @@ describe('Logistration', () => { expect(screen.getByRole('heading', { level: 3 }).textContent).toEqual('Sign in'); // verifying tabs heading for institution login true - fireEvent.click(screen.getByRole('link')); + const institutionButton = container.querySelector('button[data-event-name="institution_login"]'); + expect(institutionButton).toBeTruthy(); + fireEvent.click(institutionButton); expect(container.querySelector('#controlled-tab')).toBeDefined(); }); it('should display institution login option when secondary providers are present', () => { mergeConfig({ - DISABLE_ENTERPRISE_LOGIN: 'true', - ALLOW_PUBLIC_ACCOUNT_CREATION: 'true', + DISABLE_ENTERPRISE_LOGIN: true, + ALLOW_PUBLIC_ACCOUNT_CREATION: true, }); store = mockStore({ @@ -195,11 +205,13 @@ describe('Logistration', () => { }); const props = { selectedPage: LOGIN_PAGE }; - render(reduxWrapper()); - expect(screen.getByText('Institution/campus credentials')).toBeDefined(); + const { container } = render(reduxWrapper()); - // on clicking "Institution/campus credentials" button, it should display institution login page - fireEvent.click(screen.getByText('Institution/campus credentials')); + const institutionButton = container.querySelector('button[data-event-name="institution_login"]'); + expect(institutionButton).toBeTruthy(); + + // on clicking institution button, it should display institution login page + fireEvent.click(institutionButton); expect(screen.getByText('Test University')).toBeDefined(); mergeConfig({ @@ -209,7 +221,7 @@ describe('Logistration', () => { it('send tracking and page events when institutional login button is clicked', () => { mergeConfig({ - DISABLE_ENTERPRISE_LOGIN: 'true', + DISABLE_ENTERPRISE_LOGIN: true, }); store = mockStore({ @@ -227,7 +239,10 @@ describe('Logistration', () => { const props = { selectedPage: LOGIN_PAGE }; render(reduxWrapper()); - fireEvent.click(screen.getByText('Institution/campus credentials')); + const { container } = render(reduxWrapper()); + const institutionButton = container.querySelector('button[data-event-name="institution_login"]'); + expect(institutionButton).toBeTruthy(); + fireEvent.click(institutionButton); expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.institution_login_form.toggled', { category: 'user-engagement', app_name: APP_NAME }); expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'institution_login', { app_name: APP_NAME }); @@ -239,7 +254,7 @@ describe('Logistration', () => { it('should not display institution register button', () => { mergeConfig({ - DISABLE_ENTERPRISE_LOGIN: 'true', + DISABLE_ENTERPRISE_LOGIN: true, }); store = mockStore({ @@ -258,8 +273,12 @@ describe('Logistration', () => { delete window.location; window.location = { hostname: getConfig().SITE_NAME, href: getConfig().BASE_URL }; + const props = { selectedPage: LOGIN_PAGE }; render(reduxWrapper()); - fireEvent.click(screen.getByText('Institution/campus credentials')); + const { container } = render(reduxWrapper()); + const institutionButton = container.querySelector('button[data-event-name="institution_login"]'); + expect(institutionButton).toBeTruthy(); + fireEvent.click(institutionButton); expect(screen.getByText('Test University')).toBeDefined(); mergeConfig({ diff --git a/src/sass/_base_component.scss b/src/sass/_base_component.scss index 2804c400ca..ee45c4f50e 100644 --- a/src/sass/_base_component.scss +++ b/src/sass/_base_component.scss @@ -1,4 +1,3 @@ - .layout { display: flex; @@ -85,12 +84,11 @@ .small-screen-top-stripe { height: 0.25rem; - background-image: linear-gradient( - 102.02deg, - var(--pgn-color-brand-700), - var(--pgn-color-brand-700) 20%, - var(--pgn-color-brand-base) 20%, - ); + background-image: linear-gradient(102.02deg, + var(--pgn-color-brand-700), + var(--pgn-color-brand-700) 20%, + var(--pgn-color-brand-base) 20%, + ); background-repeat: no-repeat; } @@ -98,56 +96,54 @@ .medium-screen-top-stripe { display: flex; height: 0.5rem; - background-image: linear-gradient( - 102.02deg, - var(--pgn-color-brand-700), - var(--pgn-color-brand-700) 10%, - var(--pgn-color-brand-base) 10%, - var(--pgn-color-brand-base) 90%, - var(--pgn-color-primary-700) 90%, - var(--pgn-color-primary-700) 100%, - ); + background-image: linear-gradient(102.02deg, + var(--pgn-color-brand-700), + var(--pgn-color-brand-700) 10%, + var(--pgn-color-brand-base) 10%, + var(--pgn-color-brand-base) 90%, + var(--pgn-color-primary-700) 90%, + var(--pgn-color-primary-700) 100%, + ); background-repeat: no-repeat; } } -@media (--pgn-size-breakpoint-min-width-lg) and (--pgn-size-breakpoint-max-width-lg){ +@media (--pgn-size-breakpoint-min-width-lg) and (--pgn-size-breakpoint-max-width-lg) { .medium-screen-top-stripe { display: flex; height: 0.5rem; - background-image: linear-gradient( - 102.02deg, - var(--pgn-color-brand-700) 10%, - var(--pgn-color-brand-base) 10%, - var(--pgn-color-brand-base) 65%, - var(--pgn-color-primary-700) 65%, - var(--pgn-color-primary-700) 75%, - var(--pgn-color-accent-a) 75%, - var(--pgn-color-accent-a) 75% - ); + background-image: linear-gradient(102.02deg, + var(--pgn-color-brand-700) 10%, + var(--pgn-color-brand-base) 10%, + var(--pgn-color-brand-base) 65%, + var(--pgn-color-primary-700) 65%, + var(--pgn-color-primary-700) 75%, + var(--pgn-color-accent-a) 75%, + var(--pgn-color-accent-a) 75%); background-repeat: no-repeat; } } -.extra-large-screen-top-stripe { display: none; } +.extra-large-screen-top-stripe { + display: none; +} @media (--pgn-size-breakpoint-min-width-xl) { - .extra-large-screen-top-stripe { - display: flex; - height: 0.5rem; - background-image: linear-gradient( - 102.02deg, - var(--pgn-color-brand-700) 10%, - var(--pgn-color-brand-base) 10%, - var(--pgn-color-brand-base) 45%, - var(--pgn-color-primary-700) 45%, - var(--pgn-color-primary-700) 55%, - var(--pgn-color-accent-a) 55%, - var(--pgn-color-accent-a) 75%, - var(--pgn-color-info-200) 75%, - ); - background-repeat: no-repeat; - } + .extra-large-screen-top-stripe { + display: flex; + height: 0.5rem; + background-image: linear-gradient(102.02deg, + var(--pgn-color-brand-700) 10%, + var(--pgn-color-brand-base) 10%, + var(--pgn-color-brand-base) 45%, + var(--pgn-color-primary-700) 45%, + var(--pgn-color-primary-700) 55%, + var(--pgn-color-accent-a) 55%, + var(--pgn-color-accent-a) 75%, + var(--pgn-color-info-200) 75%, + ); + background-repeat: no-repeat; + } } .large-screen-svg-light, @@ -172,7 +168,8 @@ fill: var(--pgn-color-primary-400); } -[dir=rtl]{ +[dir=rtl] { + .medium-screen-svg-light, .medium-screen-svg-primary, .large-screen-svg-light, @@ -201,3 +198,106 @@ border: 3px solid var(--pgn-color-accent-b); transform: rotate(102.02deg); } + +.auth-hero-left { + padding-left: 3.5rem; + padding-right: 3.5rem; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.auth-hero-base-logo { + // reuse existing .logo sizes, just ensure it sits above everything + position: relative; + z-index: 2; +} + +.auth-hero-content { + // distance from top stripe & logo to hero block (matches Figma visually) + margin-top: 7rem; + max-width: 560px; +} + +/* enterprise logo in white card */ +.auth-hero-enterprise-logo-wrapper { + background: #ffffff; + border-radius: 6px; + padding: 0.75rem 1rem; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.auth-hero-enterprise-logo { + max-width: 145px; + max-height: 74px; + object-fit: contain; +} + +/* yellow slanted line */ +.auth-hero-slash { + width: 191px; + height: 250px; + flex-shrink: 1; // tune this to match logo/heading height + display: flex; + align-items: center; + justify-content: center; + + svg { + width: 100%; + height: 100%; + } +} + +/* heading text: "Start learning" + "with edX" */ +.auth-hero-heading { + display: flex; + flex-direction: column; +} + +.auth-hero-heading-line { + font-family: 'Inter', sans-serif; + font-size: 60px; + font-weight: 700; + line-height: 60px; + letter-spacing: -1.2px; + color: #FFFFFF; // for "Start learning" +} + +.auth-hero-heading-line.text-accent-a { + color: #03C7E8 !important; // for "with edX" +} + +/* enterprise message aligned under heading, same left edge */ +.auth-hero-message { + max-width: 492px; + color: #fff; + font-family: 'Inter', sans-serif; + font-size: 22px; + font-weight: 400; + line-height: 36px; +} + +.auth-hero-message p { + margin: 0; + color: inherit; + font-size: inherit; + line-height: inherit; +} + +.auth-hero-message a { + color: #03c7e8; + text-decoration: underline; +} + +/* simple responsive tweaks */ +@media (max-width: 1199.98px) { + .auth-hero-heading-line { + font-size: 2.5rem; + } + + .auth-hero-content { + margin-top: 4rem; + } +} \ No newline at end of file