diff --git a/src/common-components/EmbeddedRegistrationRoute.jsx b/src/common-components/EmbeddedRegistrationRoute.jsx index 3bf30c82b..a29bc58ed 100644 --- a/src/common-components/EmbeddedRegistrationRoute.jsx +++ b/src/common-components/EmbeddedRegistrationRoute.jsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import { Navigate } from 'react-router-dom'; -import { PAGE_NOT_FOUND } from '../data/constants'; +import { notFoundPath } from '../constants'; import { isHostAvailableInQueryParams } from '../data/utils'; /** @@ -16,7 +16,7 @@ const EmbeddedRegistrationRoute = ({ children }) => { return children; } - return ; + return ; }; EmbeddedRegistrationRoute.propTypes = { diff --git a/src/common-components/EnterpriseSSO.jsx b/src/common-components/EnterpriseSSO.jsx index a3e438878..bdc9be219 100644 --- a/src/common-components/EnterpriseSSO.jsx +++ b/src/common-components/EnterpriseSSO.jsx @@ -1,13 +1,15 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { useAppConfig, getSiteConfig, useIntl } from '@openedx/frontend-base'; +import { useAppConfig, getSiteConfig, getUrlByRouteRole, useIntl } from '@openedx/frontend-base'; import { Button, Form, Icon, } from '@openedx/paragon'; import { Login } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; +import { useNavigate } from 'react-router-dom'; -import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants'; +import { loginRole } from '../constants'; +import { SUPPORTED_ICON_CLASSES } from '../data/constants'; import messages from './messages'; /** @@ -15,6 +17,7 @@ import messages from './messages'; * */ const EnterpriseSSO = (props) => { const { formatMessage } = useIntl(); + const navigate = useNavigate(); const tpaProvider = props.provider; const hideRegistrationLink = useAppConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false || useAppConfig().SHOW_REGISTRATION_LINKS === false; @@ -26,7 +29,7 @@ const EnterpriseSSO = (props) => { const handleClick = (e) => { e.preventDefault(); - window.location.href = LOGIN_PAGE; + navigate(getUrlByRouteRole(loginRole)); }; if (tpaProvider) { diff --git a/src/common-components/RedirectLogistration.jsx b/src/common-components/RedirectLogistration.jsx index 00f65fae7..3a4d473f7 100644 --- a/src/common-components/RedirectLogistration.jsx +++ b/src/common-components/RedirectLogistration.jsx @@ -1,10 +1,9 @@ -import { useAppConfig, getSiteConfig } from '@openedx/frontend-base'; +import { useAppConfig, getSiteConfig, getUrlByRouteRole } from '@openedx/frontend-base'; import PropTypes from 'prop-types'; import { Navigate } from 'react-router-dom'; -import { - AUTHN_PROGRESSIVE_PROFILING, REDIRECT, -} from '../data/constants'; +import { welcomeRole } from '../constants'; +import { REDIRECT } from '../data/constants'; import { setCookie } from '../data/utils'; const RedirectLogistration = (props) => { @@ -48,7 +47,7 @@ const RedirectLogistration = (props) => { const registrationResult = { redirectUrl: finalRedirectUrl, success }; return ( { ); } + if (finalRedirectUrl.startsWith('/')) { + return ; + } window.location.href = finalRedirectUrl; } diff --git a/src/common-components/SocialAuthProviders.jsx b/src/common-components/SocialAuthProviders.jsx index 68715742a..fa8095fa6 100644 --- a/src/common-components/SocialAuthProviders.jsx +++ b/src/common-components/SocialAuthProviders.jsx @@ -4,7 +4,8 @@ import { Icon } from '@openedx/paragon'; import { Login } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; -import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants'; +import { loginPath } from '../constants'; +import { SUPPORTED_ICON_CLASSES } from '../data/constants'; import messages from './messages'; const SocialAuthProviders = (props) => { @@ -24,7 +25,7 @@ const SocialAuthProviders = (props) => { key={provider.id} type="button" className={`btn-social btn-${provider.id} ${index % 2 === 0 ? 'mr-3' : ''}`} - data-provider-url={referrer === LOGIN_PAGE ? provider.loginUrl : provider.registerUrl} + data-provider-url={referrer === loginPath ? provider.loginUrl : provider.registerUrl} onClick={handleSubmit} > {provider.iconImage ? ( @@ -43,7 +44,7 @@ const SocialAuthProviders = (props) => { )} - {referrer === LOGIN_PAGE + {referrer === loginPath ? formatMessage(messages['sso.sign.in.with'], { providerName: provider.name }) : formatMessage(messages['sso.create.account.using'], { providerName: provider.name })} @@ -55,7 +56,7 @@ const SocialAuthProviders = (props) => { }; SocialAuthProviders.defaultProps = { - referrer: LOGIN_PAGE, + referrer: loginPath, socialAuthProviders: [], }; diff --git a/src/common-components/ThirdPartyAuth.jsx b/src/common-components/ThirdPartyAuth.jsx index 2ebb47622..e7d6cef0f 100644 --- a/src/common-components/ThirdPartyAuth.jsx +++ b/src/common-components/ThirdPartyAuth.jsx @@ -7,9 +7,8 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import Skeleton from 'react-loading-skeleton'; -import { - ENTERPRISE_LOGIN_URL, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE, -} from '../data/constants'; +import { loginPath, registerPath } from '../constants'; +import { ENTERPRISE_LOGIN_URL, PENDING_STATE } from '../data/constants'; import messages from './messages'; import { @@ -75,7 +74,7 @@ const ThirdPartyAuth = (props) => {
)} diff --git a/src/common-components/ThirdPartyAuthAlert.jsx b/src/common-components/ThirdPartyAuthAlert.jsx index 0bdea82db..65f5f49fb 100644 --- a/src/common-components/ThirdPartyAuthAlert.jsx +++ b/src/common-components/ThirdPartyAuthAlert.jsx @@ -2,7 +2,7 @@ import { getSiteConfig, useIntl } from '@openedx/frontend-base'; import { Alert } from '@openedx/paragon'; import PropTypes from 'prop-types'; -import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants'; +import { loginPath, registerPath } from '../constants'; import messages from './messages'; const ThirdPartyAuthAlert = (props) => { @@ -11,7 +11,7 @@ const ThirdPartyAuthAlert = (props) => { const platformName = getSiteConfig().siteName; let message; - if (referrer === LOGIN_PAGE) { + if (referrer === loginPath) { message = formatMessage(messages['login.third.party.auth.account.not.linked'], { currentProvider, platformName }); } else { message = formatMessage(messages['register.third.party.auth.account.not.linked'], { currentProvider, platformName }); @@ -23,13 +23,13 @@ const ThirdPartyAuthAlert = (props) => { return ( <> - - {referrer === REGISTER_PAGE ? ( + + {referrer === registerPath ? ( {formatMessage(messages['tpa.alert.heading'])} ) : null}

{message}

- {referrer === REGISTER_PAGE ? ( + {referrer === registerPath ? (

{formatMessage(messages['registration.using.tpa.form.heading'])}

) : null} @@ -38,7 +38,7 @@ const ThirdPartyAuthAlert = (props) => { ThirdPartyAuthAlert.defaultProps = { currentProvider: '', - referrer: LOGIN_PAGE, + referrer: loginPath, }; ThirdPartyAuthAlert.propTypes = { diff --git a/src/common-components/UnAuthOnlyRoute.jsx b/src/common-components/UnAuthOnlyRoute.jsx index 545e71e7e..3150a0cf8 100644 --- a/src/common-components/UnAuthOnlyRoute.jsx +++ b/src/common-components/UnAuthOnlyRoute.jsx @@ -1,11 +1,10 @@ import { useEffect, useState } from 'react'; -import { fetchAuthenticatedUser, getAuthenticatedUser, getSiteConfig } from '@openedx/frontend-base'; +import { fetchAuthenticatedUser, getAuthenticatedUser, getUrlByRouteRole } from '@openedx/frontend-base'; import PropTypes from 'prop-types'; +import { Navigate } from 'react-router-dom'; -import { - DEFAULT_REDIRECT_URL, -} from '../data/constants'; +import { dashboardRole } from '../constants'; /** * This wrapper redirects the requester to our default redirect url if they are @@ -24,8 +23,7 @@ const UnAuthOnlyRoute = ({ children }) => { if (isReady) { if (authUser && authUser.username) { - global.location.href = getSiteConfig().lmsBaseUrl.concat(DEFAULT_REDIRECT_URL); - return null; + return ; } return children; diff --git a/src/common-components/tests/EmbeddedRegistrationRoute.test.jsx b/src/common-components/tests/EmbeddedRegistrationRoute.test.jsx index d2ce96a1a..092d9ccc8 100644 --- a/src/common-components/tests/EmbeddedRegistrationRoute.test.jsx +++ b/src/common-components/tests/EmbeddedRegistrationRoute.test.jsx @@ -1,43 +1,27 @@ -/* eslint-disable import/no-import-module-exports */ -/* eslint-disable react/function-component-definition */ - import { getSiteConfig } from '@openedx/frontend-base'; -import { render } from '@testing-library/react'; -import { act } from 'react-dom/test-utils'; +import { render, waitFor } from '@testing-library/react'; import { - MemoryRouter, Route, BrowserRouter as Router, Routes, + MemoryRouter, Navigate, Outlet, Route, Routes, } from 'react-router-dom'; -import { PAGE_NOT_FOUND, REGISTER_EMBEDDED_PAGE } from '../../data/constants'; +import { notFoundPath, registerEmbeddedPath } from '../../constants'; import EmbeddedRegistrationRoute from '../EmbeddedRegistrationRoute'; -const RRD = require('react-router-dom'); -// Just render plain div with its children -// eslint-disable-next-line react/prop-types -RRD.BrowserRouter = ({ children }) =>
{children}
; -module.exports = RRD; - -const TestApp = () => ( - -
- - Embedded Register Page} - /> - Page not found} - /> - -
-
-); - describe('EmbeddedRegistrationRoute', () => { const routerWrapper = () => ( - - + + + }> + Embedded Register Page} + /> + Page not found} + /> + + ); @@ -46,30 +30,25 @@ describe('EmbeddedRegistrationRoute', () => { }); it('should not render embedded register page if host query param is not available in the url', async () => { - let embeddedRegistrationPage = null; - await act(async () => { - const { container } = await render(routerWrapper()); - embeddedRegistrationPage = container; - }); + const { container } = render(routerWrapper()); - const renderedPage = embeddedRegistrationPage.querySelector('span'); - expect(renderedPage.textContent).toBe('Page not found'); + await waitFor(() => { + const renderedPage = container.querySelector('span'); + expect(renderedPage).not.toBeNull(); + expect(renderedPage.textContent).toBe('Page not found'); + }); }); - it('should render embedded register page if host query param is available in the url (embedded)', async () => { + it('should render embedded register page if host query param is available in the url (embedded)', () => { delete window.location; window.location = { - href: getSiteConfig().baseUrl.concat(REGISTER_EMBEDDED_PAGE), + href: getSiteConfig().baseUrl.concat('/', registerEmbeddedPath), search: '?host=http://localhost/host-websit', }; - let embeddedRegistrationPage = null; - await act(async () => { - const { container } = await render(routerWrapper()); - embeddedRegistrationPage = container; - }); + const { container } = render(routerWrapper()); - const renderedPage = embeddedRegistrationPage.querySelector('span'); + const renderedPage = container.querySelector('span'); expect(renderedPage).toBeTruthy(); expect(renderedPage.textContent).toBe('Embedded Register Page'); }); diff --git a/src/common-components/tests/ThirdPartyAuthAlert.test.jsx b/src/common-components/tests/ThirdPartyAuthAlert.test.jsx index 1d2ff0611..856919a3d 100644 --- a/src/common-components/tests/ThirdPartyAuthAlert.test.jsx +++ b/src/common-components/tests/ThirdPartyAuthAlert.test.jsx @@ -1,7 +1,8 @@ import { IntlProvider } from '@openedx/frontend-base'; import renderer from 'react-test-renderer'; -import { PENDING_STATE, REGISTER_PAGE } from '../../data/constants'; +import { registerPath } from '../../constants'; +import { PENDING_STATE } from '../../data/constants'; import ThirdPartyAuthAlert from '../ThirdPartyAuthAlert'; describe('ThirdPartyAuthAlert', () => { @@ -26,7 +27,7 @@ describe('ThirdPartyAuthAlert', () => { it('should match register page third party auth alert message snapshot', () => { props = { ...props, - referrer: REGISTER_PAGE, + referrer: registerPath, }; const tree = renderer.create( diff --git a/src/common-components/tests/UnAuthOnlyRoute.test.jsx b/src/common-components/tests/UnAuthOnlyRoute.test.jsx index aa44d8c1f..2e5fa86d1 100644 --- a/src/common-components/tests/UnAuthOnlyRoute.test.jsx +++ b/src/common-components/tests/UnAuthOnlyRoute.test.jsx @@ -9,12 +9,13 @@ import { } from 'react-router-dom'; import { UnAuthOnlyRoute } from '..'; -import { REGISTER_PAGE } from '../../data/constants'; +import { registerPath } from '../../constants'; jest.mock('@openedx/frontend-base', () => ({ ...jest.requireActual('@openedx/frontend-base'), getAuthenticatedUser: jest.fn(), fetchAuthenticatedUser: jest.fn(), + getUrlByRouteRole: jest.fn(() => '/dashboard'), })); const RRD = require('react-router-dom'); @@ -27,7 +28,7 @@ const TestApp = () => (
- Register Page} /> + Register Page} />
@@ -35,7 +36,7 @@ const TestApp = () => ( describe('UnAuthOnlyRoute', () => { const routerWrapper = () => ( - + ); diff --git a/src/constants.ts b/src/constants.ts index 400993915..3b5aed27a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1 +1,18 @@ export const appId = 'org.openedx.frontend.app.authn'; + +// Route roles +export const loginRole = 'org.openedx.frontend.role.login'; +export const registerRole = 'org.openedx.frontend.role.register'; +export const resetPasswordRole = 'org.openedx.frontend.role.resetPassword'; +export const confirmPasswordRole = 'org.openedx.frontend.role.confirmPassword'; +export const welcomeRole = 'org.openedx.frontend.role.welcome'; +export const dashboardRole = 'org.openedx.frontend.role.dashboard'; + +// Route path segments +export const loginPath = 'login'; +export const registerPath = 'register'; +export const registerEmbeddedPath = 'register-embedded'; +export const resetPath = 'reset'; +export const welcomePath = 'welcome'; +export const passwordResetConfirmPath = 'password_reset_confirm'; +export const notFoundPath = 'notfound'; diff --git a/src/data/constants.js b/src/data/constants.js index 4d1e8744a..64e25bab3 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -1,12 +1,3 @@ -// URL Paths -export const LOGIN_PAGE = '/login'; -export const REGISTER_PAGE = '/register'; -export const REGISTER_EMBEDDED_PAGE = '/register-embedded'; -export const RESET_PAGE = '/reset'; -export const AUTHN_PROGRESSIVE_PROFILING = '/welcome'; -export const DEFAULT_REDIRECT_URL = '/dashboard'; -export const PASSWORD_RESET_CONFIRM = '/password_reset_confirm/:token/'; -export const PAGE_NOT_FOUND = '/notfound'; export const ENTERPRISE_LOGIN_URL = '/enterprise/login'; // Constants diff --git a/src/data/tests/dataUtils.test.js b/src/data/tests/dataUtils.test.js index 9362e195d..a583bb544 100644 --- a/src/data/tests/dataUtils.test.js +++ b/src/data/tests/dataUtils.test.js @@ -1,10 +1,10 @@ -import { LOGIN_PAGE } from '../constants'; +import { loginPath } from '../../constants'; import { updatePathWithQueryParams } from '../utils/dataUtils'; describe('updatePathWithQueryParams', () => { it('should append query params into the path', () => { const params = '?course_id=testCourseId'; - const expectedPath = `${LOGIN_PAGE}${params}`; + const expectedPath = `${loginPath}${params}`; Object.defineProperty(window, 'location', { value: { @@ -12,7 +12,7 @@ describe('updatePathWithQueryParams', () => { search: params, }, }); - const updatedPath = updatePathWithQueryParams(LOGIN_PAGE); + const updatedPath = updatePathWithQueryParams(loginPath); expect(updatedPath).toEqual(expectedPath); }); diff --git a/src/data/utils/dataUtils.js b/src/data/utils/dataUtils.js index b39782c1f..f0f96597e 100644 --- a/src/data/utils/dataUtils.js +++ b/src/data/utils/dataUtils.js @@ -1,6 +1,8 @@ // Utility functions +import { getSiteConfig, getUrlByRouteRole } from '@openedx/frontend-base'; import * as QueryString from 'query-string'; +import { dashboardRole } from '../../constants'; import { AUTH_PARAMS } from '../constants'; export const getTpaProvider = (tpaHintProvider, primaryProviders, secondaryProviders) => { @@ -75,6 +77,19 @@ export const windowScrollTo = (options) => { return window.scrollTo(options.top, options.left); }; +/** + * Normalize a backend redirect URL: if the backend returns the LMS dashboard + * URL (or nothing), replace it with the role-based dashboard URL so that SPA + * navigation can be used when the dashboard lives in the same shell. + */ +export const normalizeRedirectUrl = (backendUrl) => { + const dashboardUrl = getUrlByRouteRole(dashboardRole) || '/'; + const lmsDashboardUrl = `${getSiteConfig().lmsBaseUrl}/dashboard`; + return (!backendUrl || backendUrl.startsWith(lmsDashboardUrl)) + ? dashboardUrl + : backendUrl; +}; + export const isHostAvailableInQueryParams = () => { const queryParams = getAllPossibleQueryParams(); return 'host' in queryParams; diff --git a/src/data/utils/index.js b/src/data/utils/index.js index ec72d451e..61fb4c222 100644 --- a/src/data/utils/index.js +++ b/src/data/utils/index.js @@ -5,6 +5,7 @@ export { getActivationStatus, isHostAvailableInQueryParams, updatePathWithQueryParams, + normalizeRedirectUrl, windowScrollTo, } from './dataUtils'; export { default as setCookie } from './cookies'; diff --git a/src/forgot-password/ForgotPasswordPage.jsx b/src/forgot-password/ForgotPasswordPage.jsx index a63fa7fcb..78ed18aad 100644 --- a/src/forgot-password/ForgotPasswordPage.jsx +++ b/src/forgot-password/ForgotPasswordPage.jsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { - getSiteConfig, sendPageEvent, sendTrackEvent, useAppConfig, useIntl, + getSiteConfig, getUrlByRouteRole, sendPageEvent, sendTrackEvent, useAppConfig, useIntl, } from '@openedx/frontend-base'; import { Form, @@ -20,7 +20,8 @@ import ForgotPasswordAlert from './ForgotPasswordAlert'; import messages from './messages'; import BaseContainer from '../base-container'; import { FormGroup } from '../common-components'; -import { LOGIN_PAGE, VALID_EMAIL_REGEX } from '../data/constants'; +import { loginPath, loginRole } from '../constants'; +import { VALID_EMAIL_REGEX } from '../data/constants'; import { updatePathWithQueryParams, windowScrollTo } from '../data/utils'; const ForgotPasswordPage = () => { @@ -115,8 +116,8 @@ const ForgotPasswordPage = () => {
- navigate(updatePathWithQueryParams(key))}> - + navigate(updatePathWithQueryParams(getUrlByRouteRole(loginRole)))}> +
diff --git a/src/forgot-password/tests/ForgotPasswordPage.test.jsx b/src/forgot-password/tests/ForgotPasswordPage.test.jsx index 36504ffbf..eb084f616 100644 --- a/src/forgot-password/tests/ForgotPasswordPage.test.jsx +++ b/src/forgot-password/tests/ForgotPasswordPage.test.jsx @@ -7,9 +7,9 @@ import { } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import { appId } from '../../constants'; +import { appId, loginPath } from '../../constants'; import { - FORBIDDEN_STATE, FORM_SUBMISSION_ERROR, INTERNAL_SERVER_ERROR, LOGIN_PAGE, + FORBIDDEN_STATE, FORM_SUBMISSION_ERROR, INTERNAL_SERVER_ERROR, } from '../../data/constants'; import { PASSWORD_RESET } from '../../reset-password/data/constants'; import { useForgotPassword } from '../data/apiHook'; @@ -26,6 +26,7 @@ jest.mock('@openedx/frontend-base', () => ({ userId: 3, username: 'test-user', })), + getUrlByRouteRole: jest.fn(() => '/login'), })); jest.mock('react-router-dom', () => ({ ...(jest.requireActual('react-router-dom')), @@ -286,7 +287,7 @@ describe('ForgotPasswordPage', () => { const navElement = container.querySelector('nav'); const anchorElement = navElement.querySelector('a'); fireEvent.click(anchorElement); - expect(mockedNavigator).toHaveBeenCalledWith(expect.stringContaining(LOGIN_PAGE)); + expect(mockedNavigator).toHaveBeenCalledWith(expect.stringContaining(loginPath)); }); it('should display token validation rate limit error message', async () => { diff --git a/src/login/ChangePasswordPrompt.jsx b/src/login/ChangePasswordPrompt.jsx index baa98d942..20858ae59 100644 --- a/src/login/ChangePasswordPrompt.jsx +++ b/src/login/ChangePasswordPrompt.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { getSiteConfig, useIntl } from '@openedx/frontend-base'; +import { getUrlByRouteRole, useIntl } from '@openedx/frontend-base'; import { ActionRow, ModalDialog, useToggle, } from '@openedx/paragon'; @@ -8,7 +8,7 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import { Link, useNavigate } from 'react-router-dom'; -import { DEFAULT_REDIRECT_URL, RESET_PAGE } from '../data/constants'; +import { dashboardRole, resetPasswordRole } from '../constants'; import { updatePathWithQueryParams } from '../data/utils'; import useMobileResponsive from '../data/utils/useMobileResponsive'; import messages from './messages'; @@ -21,7 +21,12 @@ const ChangePasswordPrompt = ({ variant, redirectUrl }) => { if (variant === 'block') { setRedirectToResetPasswordPage(true); } else { - window.location.href = redirectUrl || getSiteConfig().lmsBaseUrl.concat(DEFAULT_REDIRECT_URL); + const url = redirectUrl || getUrlByRouteRole(dashboardRole) || '/'; + if (url.startsWith('/')) { + navigate(url); + } else { + window.location.href = url; + } } }, }; @@ -32,7 +37,7 @@ const ChangePasswordPrompt = ({ variant, redirectUrl }) => { useEffect(() => { if (redirectToResetPasswordPage) { - navigate(updatePathWithQueryParams(RESET_PAGE)); + navigate(updatePathWithQueryParams(getUrlByRouteRole(resetPasswordRole))); } }, [redirectToResetPasswordPage, navigate]); @@ -68,7 +73,7 @@ const ChangePasswordPrompt = ({ variant, redirectUrl }) => { 'btn btn-primary', { 'w-100': isMobileView }, )} - to={updatePathWithQueryParams(RESET_PAGE)} + to={updatePathWithQueryParams(getUrlByRouteRole(resetPasswordRole))} > {formatMessage(messages['password.security.redirect.to.reset.password.button'])} diff --git a/src/login/LoginPage.jsx b/src/login/LoginPage.jsx index 839f955c4..aabade48c 100644 --- a/src/login/LoginPage.jsx +++ b/src/login/LoginPage.jsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; import { - getSiteConfig, sendPageEvent, sendTrackEvent, useIntl + fetchAuthenticatedUser, hydrateAuthenticatedUser, getSiteConfig, getUrlByRouteRole, + sendPageEvent, sendTrackEvent, useIntl, } from '@openedx/frontend-base'; import { Form, StatefulButton } from '@openedx/paragon'; import PropTypes from 'prop-types'; @@ -21,7 +22,8 @@ import { useThirdPartyAuthContext } from '../common-components/components/ThirdP import { useThirdPartyAuthHook } from '../common-components/data/apiHook'; import EnterpriseSSO from '../common-components/EnterpriseSSO'; import ThirdPartyAuth from '../common-components/ThirdPartyAuth'; -import { LOGIN_PAGE, PENDING_STATE, RESET_PAGE } from '../data/constants'; +import { loginPath, resetPasswordRole } from '../constants'; +import { PENDING_STATE } from '../data/constants'; import { getActivationStatus, getAllPossibleQueryParams, @@ -65,8 +67,15 @@ const LoginPage = ({ context: {}, }); const { mutate: loginUser, isPending: isLoggingIn } = useLogin({ - onSuccess: (data) => { - setLoginResult({ success: true, redirectUrl: data.redirectUrl || '' }); + onSuccess: async (data) => { + const redirectUrl = localNextPath || data.redirectUrl || ''; + if (redirectUrl.startsWith('/')) { + await fetchAuthenticatedUser({ forceRefresh: true }); + // Hydrate in the background — publishes AUTHENTICATED_USER_CHANGED after + // SPA navigation, so the header picks up the full user profile (avatar, etc.) + hydrateAuthenticatedUser(); + } + setLoginResult({ success: true, redirectUrl }); }, onError: (formattedError) => { setErrorCode(prev => ({ @@ -90,13 +99,14 @@ const LoginPage = ({ const { formatMessage } = useIntl(); const activationMsgType = getActivationStatus(); const queryParams = useMemo(() => getAllPossibleQueryParams(), []); + const localNextPath = queryParams.next?.startsWith('/') ? queryParams.next : null; const tpaHint = useMemo(() => getTpaHint(), []); const params = { ...queryParams }; if (tpaHint) { params.tpa_hint = tpaHint; } - const { data, isSuccess, error } = useThirdPartyAuthHook(LOGIN_PAGE, params); + const { data, isSuccess, error } = useThirdPartyAuthHook(loginPath, params); useEffect(() => { sendPageEvent('login_and_registration', 'login'); @@ -290,7 +300,7 @@ const LoginPage = ({ id="forgot-password" name="forgot-password" className="btn btn-link font-weight-500 text-body" - to={updatePathWithQueryParams(RESET_PAGE)} + to={updatePathWithQueryParams(getUrlByRouteRole(resetPasswordRole))} onClick={trackForgotPasswordLinkClick} > {formatMessage(messages['forgot.password'])} diff --git a/src/login/data/api.test.ts b/src/login/data/api.test.ts index a99df1585..25477c36e 100644 --- a/src/login/data/api.test.ts +++ b/src/login/data/api.test.ts @@ -170,12 +170,13 @@ describe('login api', () => { extra_data: { some: 'value' }, }; const mockResponse = { data: mockResponseData }; + // normalizeRedirectUrl replaces the LMS dashboard URL with the role-based one const expectedCamelCaseInput = { - redirectUrl: 'http://localhost:18000/dashboard', + redirectUrl: '/dashboard', success: true, }; const expectedResult = { - redirectUrl: 'http://localhost:18000/dashboard', + redirectUrl: '/dashboard', success: true, }; diff --git a/src/login/data/api.ts b/src/login/data/api.ts index a67e9b1f2..7b563dfc3 100644 --- a/src/login/data/api.ts +++ b/src/login/data/api.ts @@ -1,6 +1,8 @@ -import { camelCaseObject, getAuthenticatedHttpClient, getSiteConfig, getUrlByRouteRole } from '@openedx/frontend-base'; +import { camelCaseObject, getAuthenticatedHttpClient, getSiteConfig } from '@openedx/frontend-base'; import * as QueryString from 'query-string'; +import { normalizeRedirectUrl } from '../../data/utils'; + const login = async (creds) => { const requestConfig = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -9,9 +11,8 @@ const login = async (creds) => { const url = `${getSiteConfig().lmsBaseUrl}/api/user/v2/account/login_session/`; const { data } = await getAuthenticatedHttpClient() .post(url, QueryString.stringify(creds), requestConfig); - const defaultRedirectUrl = getUrlByRouteRole('org.openedx.frontend.role.dashboard'); return camelCaseObject({ - redirectUrl: data.redirect_url || defaultRedirectUrl, + redirectUrl: normalizeRedirectUrl(data.redirect_url || ''), success: data.success || false, }); }; diff --git a/src/login/tests/ChangePasswordPrompt.test.jsx b/src/login/tests/ChangePasswordPrompt.test.jsx index 0fcfd4e41..1c2a23f23 100644 --- a/src/login/tests/ChangePasswordPrompt.test.jsx +++ b/src/login/tests/ChangePasswordPrompt.test.jsx @@ -5,11 +5,15 @@ import { import { act } from 'react-dom/test-utils'; import { MemoryRouter } from 'react-router-dom'; -import { RESET_PAGE } from '../../data/constants'; import ChangePasswordPrompt from '../ChangePasswordPrompt'; const mockedNavigator = jest.fn(); +jest.mock('@openedx/frontend-base', () => ({ + ...jest.requireActual('@openedx/frontend-base'), + getUrlByRouteRole: jest.fn(() => '/mock-url'), +})); + jest.mock('react-router-dom', () => ({ ...(jest.requireActual('react-router-dom')), useNavigate: () => mockedNavigator, @@ -69,6 +73,6 @@ describe('ChangePasswordPromptTests', () => { )); }); - expect(mockedNavigator).toHaveBeenCalledWith(RESET_PAGE); + expect(mockedNavigator).toHaveBeenCalledWith('/mock-url'); }); }); diff --git a/src/login/tests/LoginPage.test.jsx b/src/login/tests/LoginPage.test.jsx index 2deb030cb..1a0e72ff1 100644 --- a/src/login/tests/LoginPage.test.jsx +++ b/src/login/tests/LoginPage.test.jsx @@ -11,7 +11,8 @@ import { MemoryRouter } from 'react-router-dom'; import { useThirdPartyAuthContext } from '../../common-components/components/ThirdPartyAuthContext'; import { useThirdPartyAuthHook } from '../../common-components/data/apiHook'; import { appId } from '../../constants'; -import { COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE } from '../../data/constants'; +import { loginPath } from '../../constants'; +import { COMPLETE_STATE, PENDING_STATE } from '../../data/constants'; import { RegisterProvider } from '../../register/components/RegisterContext'; import { LoginProvider } from '../components/LoginContext'; import { useLogin } from '../data/apiHook'; @@ -28,6 +29,7 @@ jest.mock('@openedx/frontend-base', () => ({ sendPageEvent: jest.fn(), sendTrackEvent: jest.fn(), getAuthService: jest.fn(), + getUrlByRouteRole: jest.fn(() => '/mock-url'), })); // jest.mock() must be called before importing the mocked module's members, @@ -467,7 +469,7 @@ describe('LoginPage', () => { useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext); delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` }; + window.location = { href: getSiteConfig().baseUrl.concat('/', loginPath), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` }; render(queryWrapper()); expect(screen.getByText( @@ -489,7 +491,7 @@ describe('LoginPage', () => { useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext); delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` }; + window.location = { href: getSiteConfig().baseUrl.concat('/', loginPath), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` }; const { container } = render(queryWrapper()); expect(container.querySelector('.react-loading-skeleton')).toBeTruthy(); @@ -505,7 +507,7 @@ describe('LoginPage', () => { useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext); delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${secondaryProviders.id}` }; + window.location = { href: getSiteConfig().baseUrl.concat('/', loginPath), search: `?next=/dashboard&tpa_hint=${secondaryProviders.id}` }; secondaryProviders.iconImage = null; render(queryWrapper()); @@ -521,7 +523,7 @@ describe('LoginPage', () => { useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext); delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(LOGIN_PAGE), search: '?next=/dashboard&tpa_hint=invalid' }; + window.location = { href: getSiteConfig().baseUrl.concat('/', loginPath), search: '?next=/dashboard&tpa_hint=invalid' }; const { container } = render(queryWrapper()); expect(container.querySelector(`#${ssoProvider.id}`).querySelector('#provider-name').textContent).toEqual(`${ssoProvider.name}`); @@ -545,7 +547,7 @@ describe('LoginPage', () => { }); delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(LOGIN_PAGE), search: `?tpa_hint=${ssoProvider.id}` }; + window.location = { href: getSiteConfig().baseUrl.concat('/', loginPath), search: `?tpa_hint=${ssoProvider.id}` }; render(queryWrapper()); expect(screen.getByText( @@ -566,7 +568,7 @@ describe('LoginPage', () => { useThirdPartyAuthContext.mockReturnValue(mockThirdPartyAuthContext); delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(LOGIN_PAGE), search: `?tpa_hint=${ssoProvider.id}` }; + window.location = { href: getSiteConfig().baseUrl.concat('/', loginPath), search: `?tpa_hint=${ssoProvider.id}` }; render(queryWrapper()); expect(screen.getByText( @@ -698,7 +700,7 @@ describe('LoginPage', () => { const wrapper = (children) => ( - + diff --git a/src/logistration/Logistration.jsx b/src/logistration/Logistration.jsx index d35be45f1..191a4ce08 100644 --- a/src/logistration/Logistration.jsx +++ b/src/logistration/Logistration.jsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import { - useAppConfig, getAuthService, getSiteConfig, sendPageEvent, sendTrackEvent, useIntl + useAppConfig, getAuthService, getSiteConfig, getUrlByRouteRole, + sendPageEvent, sendTrackEvent, useIntl, } from '@openedx/frontend-base'; import { Icon, @@ -15,7 +16,7 @@ import { Navigate, useNavigate } from 'react-router-dom'; import BaseContainer from '../base-container'; import { ThirdPartyAuthProvider, useThirdPartyAuthContext } from '../common-components/components/ThirdPartyAuthContext'; import messages from '../common-components/messages'; -import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants'; +import { loginPath, loginRole, registerPath, registerRole } from '../constants'; import { getTpaHint, getTpaProvider, updatePathWithQueryParams, } from '../data/utils'; @@ -54,14 +55,14 @@ const LogistrationPageInner = ({ useEffect(() => { if (disablePublicAccountCreation) { - navigate(updatePathWithQueryParams(LOGIN_PAGE)); + navigate(updatePathWithQueryParams(getUrlByRouteRole(loginRole))); } }, [navigate, disablePublicAccountCreation]); const handleInstitutionLogin = (e) => { sendTrackEvent('edx.bi.institution_login_form.toggled', { category: 'user-engagement' }); if (typeof e === 'string') { - sendPageEvent('login_and_registration', e === '/login' ? 'login' : 'register'); + sendPageEvent('login_and_registration', e === loginPath ? 'login' : 'register'); } else { sendPageEvent('login_and_registration', e.target.dataset.eventName); } @@ -72,7 +73,7 @@ const LogistrationPageInner = ({ if (tabKey === currentTab) { return; } - sendTrackEvent(`edx.bi.${tabKey.replace('/', '')}_form.toggled`, { category: 'user-engagement' }); + sendTrackEvent(`edx.bi.${tabKey}_form.toggled`, { category: 'user-engagement' }); clearThirdPartyAuthErrorMessage(); setKey(tabKey); }; @@ -81,7 +82,7 @@ const LogistrationPageInner = ({
- {selectedPage === LOGIN_PAGE + {selectedPage === loginPath ? formatMessage(messages['logistration.sign.in']) : formatMessage(messages['logistration.register'])} @@ -101,7 +102,7 @@ const LogistrationPageInner = ({ <> {institutionLogin && ( - + )}
@@ -120,7 +121,7 @@ const LogistrationPageInner = ({ {institutionLogin ? ( - + ) : (!isValidTpaHint() && !hideRegistrationLink && ( @@ -129,20 +130,20 @@ const LogistrationPageInner = ({ id="controlled-tab" onSelect={(tabKey) => handleOnSelect(tabKey, selectedPage)} > - - + + ))} {key && ( - + )}
{!institutionLogin && !isValidTpaHint() && hideRegistrationLink && (

- {formatMessage(messages[selectedPage === LOGIN_PAGE ? 'logistration.sign.in' : 'logistration.register'])} + {formatMessage(messages[selectedPage === loginPath ? 'logistration.sign.in' : 'logistration.register'])}

)} - {selectedPage === LOGIN_PAGE + {selectedPage === loginPath ? ( ({ getCsrfToken: mockGetCsrfToken, }), })), + getUrlByRouteRole: jest.fn(() => '/mock-url'), })); // Mock the apiHook to prevent actual API calls @@ -112,9 +113,9 @@ describe('Logistration', () => { SHOW_REGISTRATION_LINKS: true, }); - const { container } = render(renderWrapper()); + const { container } = render(renderWrapper()); // While staying on the registration form, clicking the register tab again - fireEvent.click(container.querySelector('a[data-rb-event-key="/register"]')); + fireEvent.click(container.querySelector('a[data-rb-event-key="register"]')); expect(sendTrackEvent).not.toHaveBeenCalled(); }); @@ -124,13 +125,13 @@ describe('Logistration', () => { ALLOW_PUBLIC_ACCOUNT_CREATION: true, }); - const { container } = render(renderWrapper()); + const { container } = render(renderWrapper()); expect(container.querySelector('RegistrationPage')).toBeDefined(); }); it('should render login page', () => { - const props = { selectedPage: LOGIN_PAGE }; + const props = { selectedPage: loginPath }; const { container } = render(renderWrapper()); expect(container.querySelector('LoginPage')).toBeDefined(); @@ -142,7 +143,7 @@ describe('Logistration', () => { SHOW_REGISTRATION_LINKS: false, }); - let props = { selectedPage: LOGIN_PAGE }; + let props = { selectedPage: loginPath }; const { rerender } = render(renderWrapper()); // verifying sign in heading @@ -150,7 +151,7 @@ describe('Logistration', () => { // register page is still accessible when SHOW_REGISTRATION_LINKS is false // but it needs to be accessed directly - props = { selectedPage: REGISTER_PAGE }; + props = { selectedPage: registerPath }; rerender(renderWrapper()); // verifying register heading @@ -164,7 +165,7 @@ describe('Logistration', () => { SHOW_REGISTRATION_LINKS: 'true', }); - const props = { selectedPage: LOGIN_PAGE }; + const props = { selectedPage: loginPath }; const { container } = render(renderWrapper()); // verifying sign in heading for institution login false @@ -196,7 +197,7 @@ describe('Logistration', () => { }, }); - const props = { selectedPage: LOGIN_PAGE }; + const props = { selectedPage: loginPath }; render(renderWrapper()); expect(screen.getByText('Institution/campus credentials')).toBeDefined(); @@ -228,7 +229,7 @@ describe('Logistration', () => { }, }); - const props = { selectedPage: LOGIN_PAGE }; + const props = { selectedPage: loginPath }; render(renderWrapper()); fireEvent.click(screen.getByText('Institution/campus credentials')); @@ -262,7 +263,7 @@ describe('Logistration', () => { delete window.location; window.location = { hostname: getSiteConfig().siteName, href: getSiteConfig().baseUrl }; - render(renderWrapper()); + render(renderWrapper()); fireEvent.click(screen.getByText('Institution/campus credentials')); expect(screen.getByText('Test University')).toBeDefined(); @@ -272,29 +273,29 @@ describe('Logistration', () => { }); it('should switch to login tab when login tab is clicked', () => { - const { container } = render(renderWrapper()); - fireEvent.click(container.querySelector('a[data-rb-event-key="/login"]')); + const { container } = render(renderWrapper()); + fireEvent.click(container.querySelector('a[data-rb-event-key="login"]')); // Verify the tab switch occurred expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.login_form.toggled', { category: 'user-engagement' }); }); it('should switch to register tab when register tab is clicked', () => { - const props = { selectedPage: LOGIN_PAGE }; + const props = { selectedPage: loginPath }; const { container } = render(renderWrapper()); - fireEvent.click(container.querySelector('a[data-rb-event-key="/register"]')); + fireEvent.click(container.querySelector('a[data-rb-event-key="register"]')); // Verify the tab switch occurred expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.register_form.toggled', { category: 'user-engagement' }); }); it('should clear tpa context errorMessage tab click', () => { - const { container } = render(renderWrapper()); + const { container } = render(renderWrapper()); - fireEvent.click(container.querySelector('a[data-rb-event-key="/login"]')); + fireEvent.click(container.querySelector('a[data-rb-event-key="login"]')); expect(mockClearThirdPartyAuthErrorMessage).toHaveBeenCalled(); }); it('should call authService getCsrfTokenService on component mount', () => { - render(renderWrapper()); + render(renderWrapper()); expect(mockGetCsrfToken).toHaveBeenCalledWith(getSiteConfig().lmsBaseUrl); }); @@ -319,13 +320,13 @@ describe('Logistration', () => { }); // Login page - render(renderWrapper()); + render(renderWrapper()); fireEvent.click(screen.getByText('Institution/campus credentials')); expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'institution_login'); // Register page sendPageEvent.mockClear(); - render(renderWrapper()); + render(renderWrapper()); fireEvent.click(screen.getByText('Institution/campus credentials')); expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'institution_login'); @@ -353,7 +354,7 @@ describe('Logistration', () => { }, }); - render(renderWrapper()); + render(renderWrapper()); sendPageEvent.mockClear(); fireEvent.click(screen.getByText('Institution/campus credentials')); expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.institution_login_form.toggled', { category: 'user-engagement' }); diff --git a/src/progressive-profiling/ProgressiveProfiling.jsx b/src/progressive-profiling/ProgressiveProfiling.jsx index b6c10105b..429f3165f 100644 --- a/src/progressive-profiling/ProgressiveProfiling.jsx +++ b/src/progressive-profiling/ProgressiveProfiling.jsx @@ -6,6 +6,7 @@ import { getAuthenticatedUser, getLoggingService, getSiteConfig, + getUrlByRouteRole, identifyAuthenticatedUser, sendPageEvent, sendTrackEvent, @@ -22,7 +23,7 @@ import { } from '@openedx/paragon'; import { Error } from '@openedx/paragon/icons'; import { Helmet } from 'react-helmet'; -import { useLocation } from 'react-router-dom'; +import { Navigate, useLocation } from 'react-router-dom'; import { ProgressiveProfilingProvider, useProgressiveProfilingContext } from './components/ProgressiveProfilingContext'; import messages from './messages'; @@ -32,10 +33,9 @@ import { RedirectLogistration } from '../common-components'; import { useSaveUserProfile } from './data/apiHook'; import { ThirdPartyAuthProvider, useThirdPartyAuthContext } from '../common-components/components/ThirdPartyAuthContext'; import { useThirdPartyAuthHook } from '../common-components/data/apiHook'; +import { dashboardRole, welcomePath } from '../constants'; import { - AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, - DEFAULT_REDIRECT_URL, FAILURE_STATE, PENDING_STATE, } from '../data/constants'; @@ -76,7 +76,7 @@ const ProgressiveProfilingInner = () => { const [values, setValues] = useState({}); const [showModal, setShowModal] = useState(false); - const { data, isSuccess, error } = useThirdPartyAuthHook(AUTHN_PROGRESSIVE_PROFILING, + const { data, isSuccess, error } = useThirdPartyAuthHook(welcomePath, { is_welcome_page: true, next: queryParams?.next }, { enabled: registrationEmbedded }); useEffect(() => { @@ -132,8 +132,11 @@ const ProgressiveProfilingInner = () => { || thirdPartyAuthApiStatus === FAILURE_STATE || (thirdPartyAuthApiStatus === COMPLETE_STATE && !Object.keys(welcomePageContext).includes('fields')) ) { - const DASHBOARD_URL = getSiteConfig().lmsBaseUrl.concat(DEFAULT_REDIRECT_URL); - global.location.assign(DASHBOARD_URL); + const dashboardUrl = getUrlByRouteRole(dashboardRole) || '/'; + if (dashboardUrl.startsWith('/')) { + return ; + } + window.location.href = dashboardUrl; return null; } diff --git a/src/progressive-profiling/ProgressiveProfilingPageModal.jsx b/src/progressive-profiling/ProgressiveProfilingPageModal.jsx index 1793149cc..57f9d8162 100644 --- a/src/progressive-profiling/ProgressiveProfilingPageModal.jsx +++ b/src/progressive-profiling/ProgressiveProfilingPageModal.jsx @@ -1,17 +1,23 @@ import { getSiteConfig, useIntl } from '@openedx/frontend-base'; import { ActionRow, Button, ModalDialog } from '@openedx/paragon'; import PropTypes from 'prop-types'; +import { useNavigate } from 'react-router-dom'; import messages from './messages'; const ProgressiveProfilingPageModal = (props) => { const { formatMessage } = useIntl(); + const navigate = useNavigate(); const { isOpen, redirectUrl } = props; const platformName = getSiteConfig().siteName; const handleSubmit = (e) => { e.preventDefault(); - window.location.href = redirectUrl; + if (redirectUrl.startsWith('/')) { + navigate(redirectUrl); + } else { + window.location.href = redirectUrl; + } }; return ( diff --git a/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx b/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx index 3ab295995..1f9a02bba 100644 --- a/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx +++ b/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx @@ -15,11 +15,9 @@ import { import { MemoryRouter, useLocation } from 'react-router-dom'; import { useThirdPartyAuthContext } from '../../common-components/components/ThirdPartyAuthContext'; -import { appId } from '../../constants'; +import { appId, welcomePath } from '../../constants'; import { - AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, - DEFAULT_REDIRECT_URL, EMBEDDED, PENDING_STATE, } from '../../data/constants'; @@ -99,6 +97,7 @@ jest.mock('@openedx/frontend-base', () => ({ configureAuth: jest.fn(), getAuthenticatedUser: jest.fn(), getLoggingService: jest.fn(), + getUrlByRouteRole: jest.fn(() => '/dashboard'), })); // Create mock function outside to access it directly @@ -121,8 +120,8 @@ jest.mock('react-router-dom', () => { describe('ProgressiveProfilingTests', () => { let queryClient; - const DASHBOARD_URL = getSiteConfig().lmsBaseUrl.concat(DEFAULT_REDIRECT_URL); - const registrationResult = { redirectUrl: getSiteConfig().lmsBaseUrl + DEFAULT_REDIRECT_URL, success: true }; + const DASHBOARD_URL = '/dashboard'; + const registrationResult = { redirectUrl: DASHBOARD_URL, success: true }; const fields = { company: { name: 'company', type: 'text', label: 'Company' }, gender: { @@ -229,7 +228,7 @@ describe('ProgressiveProfilingTests', () => { it('should open modal on pressing skip for now button', () => { delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(AUTHN_PROGRESSIVE_PROFILING) }; + window.location = { href: getSiteConfig().baseUrl.concat('/', welcomePath) }; const { getByRole } = renderWithProviders(); const skipButton = getByRole('button', { name: /skip for now/i }); @@ -272,7 +271,7 @@ describe('ProgressiveProfilingTests', () => { host: '', }; delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(AUTHN_PROGRESSIVE_PROFILING) }; + window.location = { href: getSiteConfig().baseUrl.concat('/', welcomePath) }; renderWithProviders(); const nextButton = screen.getByText('Submit'); @@ -313,14 +312,9 @@ describe('ProgressiveProfilingTests', () => { it('should redirect to login page if unauthenticated user tries to access welcome page', () => { getAuthenticatedUser.mockReturnValue(null); - delete window.location; - window.location = { - assign: jest.fn().mockImplementation((value) => { window.location.href = value; }), - href: getSiteConfig().baseUrl, - }; renderWithProviders(); - expect(window.location.href).toEqual(DASHBOARD_URL); + expect(mockNavigate).toHaveBeenCalledWith(DASHBOARD_URL); }); describe('Embedded Form Workflow Test', () => { @@ -344,7 +338,7 @@ describe('ProgressiveProfilingTests', () => { it('should set host property value embedded host for on ramp experience for skip link event', () => { delete window.location; window.location = { - href: getSiteConfig().baseUrl.concat(AUTHN_PROGRESSIVE_PROFILING), + href: getSiteConfig().baseUrl.concat('/', welcomePath), search: `?host=${host}&variant=${EMBEDDED}`, }; renderWithProviders(); @@ -359,7 +353,7 @@ describe('ProgressiveProfilingTests', () => { delete window.location; window.location = { assign: jest.fn().mockImplementation((value) => { window.location.href = value; }), - href: getSiteConfig().baseUrl.concat(AUTHN_PROGRESSIVE_PROFILING), + href: getSiteConfig().baseUrl.concat('/', welcomePath), search: `?host=${host}&variant=${EMBEDDED}`, }; @@ -385,7 +379,7 @@ describe('ProgressiveProfilingTests', () => { }; delete window.location; window.location = { - href: getSiteConfig().baseUrl.concat(AUTHN_PROGRESSIVE_PROFILING), + href: getSiteConfig().baseUrl.concat('/', welcomePath), search: `?host=${host}`, }; renderWithProviders(); @@ -412,13 +406,12 @@ describe('ProgressiveProfilingTests', () => { it('should redirect to dashboard if API call to get form field fails', () => { delete window.location; window.location = { - assign: jest.fn().mockImplementation((value) => { window.location.href = value; }), href: getSiteConfig().baseUrl, search: `?variant=${EMBEDDED}`, }; renderWithProviders(); - expect(window.location.href).toBe(DASHBOARD_URL); + expect(mockNavigate).toHaveBeenCalledWith(DASHBOARD_URL); }); it('should redirect to provided redirect url', () => { @@ -530,7 +523,7 @@ describe('ProgressiveProfilingTests', () => { delete window.location; window.location = { - href: getSiteConfig().baseUrl.concat(AUTHN_PROGRESSIVE_PROFILING), + href: getSiteConfig().baseUrl.concat('/', welcomePath), search: '?variant=embedded&host=http://example.com', }; mockThirdPartyAuthHook.data = mockThirdPartyData; @@ -545,7 +538,7 @@ describe('ProgressiveProfilingTests', () => { it('should not call third party auth functions when not in embedded mode', () => { delete window.location; window.location = { - href: getSiteConfig().baseUrl.concat(AUTHN_PROGRESSIVE_PROFILING), + href: getSiteConfig().baseUrl.concat('/', welcomePath), search: '', }; diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 9e3c88ba1..9db8ad65d 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -1,6 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; import { + fetchAuthenticatedUser, + hydrateAuthenticatedUser, useAppConfig, getSiteConfig, sendPageEvent, sendTrackEvent, @@ -35,9 +37,8 @@ import { useThirdPartyAuthContext } from '../common-components/components/ThirdP import { useThirdPartyAuthHook } from '../common-components/data/apiHook'; import EnterpriseSSO from '../common-components/EnterpriseSSO'; import ThirdPartyAuth from '../common-components/ThirdPartyAuth'; -import { - COMPLETE_STATE, DEFAULT_STATE, PENDING_STATE, REGISTER_PAGE, -} from '../data/constants'; +import { registerPath } from '../constants'; +import { COMPLETE_STATE, DEFAULT_STATE, PENDING_STATE } from '../data/constants'; import { getAllPossibleQueryParams, getTpaHint, getTpaProvider, isHostAvailableInQueryParams, setCookie, } from '../data/utils'; @@ -108,8 +109,15 @@ const RegistrationPage = (props) => { const backendRegistrationError = registrationError; const registrationMutation = useRegistration({ - onSuccess: (data) => { - setRegistrationResult(data); + onSuccess: async (data) => { + const redirectUrl = localNextPath || data.redirectUrl || ''; + if (redirectUrl.startsWith('/')) { + await fetchAuthenticatedUser({ forceRefresh: true }); + // Hydrate in the background — publishes AUTHENTICATED_USER_CHANGED after + // SPA navigation, so the header picks up the full user profile (avatar, etc.) + hydrateAuthenticatedUser(); + } + setRegistrationResult({ ...data, redirectUrl }); setRegistrationError({}); }, onError: (errorData) => { @@ -121,6 +129,7 @@ const RegistrationPage = (props) => { const registrationErrorCode = registrationError?.errorCode || backendRegistrationError?.errorCode; const submitState = registrationMutation.isPending ? PENDING_STATE : DEFAULT_STATE; const queryParams = useMemo(() => getAllPossibleQueryParams(), []); + const localNextPath = queryParams.next?.startsWith('/') ? queryParams.next : null; const tpaHint = useMemo(() => getTpaHint(), []); // Initialize form state from local backedUpFormData const backedUpFormData = registrationFormData; @@ -163,7 +172,7 @@ const RegistrationPage = (props) => { if (tpaHint) { params.tpa_hint = tpaHint; } - const { data, isSuccess, error } = useThirdPartyAuthHook(REGISTER_PAGE, params); + const { data, isSuccess, error } = useThirdPartyAuthHook(registerPath, params); useEffect(() => { if (!formStartTime) { sendPageEvent('login_and_registration', 'register'); @@ -299,7 +308,6 @@ const RegistrationPage = (props) => { totalRegistrationTime, queryParams, ); - // making register call with React Query registrationMutation.mutate(payload); }; @@ -355,7 +363,7 @@ const RegistrationPage = (props) => { ({ @@ -41,6 +39,7 @@ jest.mock('@openedx/frontend-base', () => ({ sendPageEvent: jest.fn(), sendTrackEvent: jest.fn(), getLocale: jest.fn(), + getUrlByRouteRole: jest.fn(() => '/dashboard'), })); // jest.mock() must be called before importing the mocked module's members, @@ -266,7 +265,6 @@ describe('RegistrationPage', () => { password: 'password1', country: 'Pakistan', total_registration_time: 0, - next: '/course/demo-course-url', }; const { getByLabelText, container } = render(renderWrapper()); @@ -274,7 +272,7 @@ describe('RegistrationPage', () => { const button = container.querySelector('button.btn-brand'); fireEvent.click(button); - expect(mockRegistrationMutation.mutate).toHaveBeenCalledWith({ ...payload, country: 'PK' }); + expect(mockRegistrationMutation.mutate).toHaveBeenCalledWith({ ...payload, country: 'PK', next: '/course/demo-course-url' }); }); it('should submit form without password field when current provider is present', () => { @@ -828,7 +826,7 @@ describe('RegistrationPage', () => { window.parent.postMessage = jest.fn(); delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(AUTHN_PROGRESSIVE_PROFILING), search: '?host=http://localhost/host-website' }; + window.location = { href: getSiteConfig().baseUrl.concat('/', welcomePath), search: '?host=http://localhost/host-website' }; // Mock successful registration result useRegisterContext.mockReturnValue({ @@ -853,7 +851,7 @@ describe('RegistrationPage', () => { it('should not display validations error on blur event when embedded variant is rendered', () => { delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(REGISTER_PAGE), search: '?host=http://localhost/host-website' }; + window.location = { href: getSiteConfig().baseUrl.concat('/', registerPath), search: '?host=http://localhost/host-website' }; const { container } = render(renderWrapper()); const usernameInput = container.querySelector('input#username'); @@ -867,7 +865,7 @@ describe('RegistrationPage', () => { it('should set errors in temporary state when validations are returned by registration api', () => { delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(REGISTER_PAGE), search: '?host=http://localhost/host-website' }; + window.location = { href: getSiteConfig().baseUrl.concat('/', registerPath), search: '?host=http://localhost/host-website' }; const usernameError = 'It looks like this username is already taken'; const emailError = 'This email is already associated with an existing or previous account'; @@ -891,7 +889,7 @@ describe('RegistrationPage', () => { it('should clear error on focus for embedded experience also', () => { delete window.location; window.location = { - href: getSiteConfig().baseUrl.concat(REGISTER_PAGE), + href: getSiteConfig().baseUrl.concat('/', registerPath), search: '?host=http://localhost/host-website', }; diff --git a/src/register/components/tests/ThirdPartyAuth.test.jsx b/src/register/components/tests/ThirdPartyAuth.test.jsx index 8353abdce..a4522f7ec 100644 --- a/src/register/components/tests/ThirdPartyAuth.test.jsx +++ b/src/register/components/tests/ThirdPartyAuth.test.jsx @@ -6,10 +6,8 @@ import { fireEvent, render } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { useThirdPartyAuthContext } from '../../../common-components/components/ThirdPartyAuthContext'; -import { appId } from '../../../constants'; -import { - COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE, -} from '../../../data/constants'; +import { appId, loginPath, registerPath } from '../../../constants'; +import { COMPLETE_STATE, PENDING_STATE } from '../../../data/constants'; import { useFieldValidations, useRegistration } from '../../data/apiHook'; import RegistrationPage from '../../RegistrationPage'; import { useRegisterContext } from '../RegisterContext'; @@ -19,6 +17,7 @@ jest.mock('@openedx/frontend-base', () => ({ sendPageEvent: jest.fn(), sendTrackEvent: jest.fn(), getLocale: jest.fn(), + getUrlByRouteRole: jest.fn(() => '/mock-url'), })); // jest.mock() must be called before importing the mocked module's members, @@ -237,7 +236,7 @@ describe('ThirdPartyAuth', () => { }); delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(LOGIN_PAGE), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` }; + window.location = { href: getSiteConfig().baseUrl.concat('/', loginPath), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` }; const { container } = render( routerWrapper(renderWrapper()), @@ -260,7 +259,7 @@ describe('ThirdPartyAuth', () => { delete window.location; window.location = { - href: getSiteConfig().baseUrl.concat(LOGIN_PAGE), + href: getSiteConfig().baseUrl.concat('/', loginPath), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}`, }; @@ -281,7 +280,7 @@ describe('ThirdPartyAuth', () => { }); delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(REGISTER_PAGE), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` }; + window.location = { href: getSiteConfig().baseUrl.concat('/', registerPath), search: `?next=/dashboard&tpa_hint=${ssoProvider.id}` }; ssoProvider.iconImage = null; const { container } = render(routerWrapper(renderWrapper())); @@ -303,7 +302,7 @@ describe('ThirdPartyAuth', () => { }); delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(REGISTER_PAGE), search: `?next=/dashboard&tpa_hint=${secondaryProviders.id}` }; + window.location = { href: getSiteConfig().baseUrl.concat('/', registerPath), search: `?next=/dashboard&tpa_hint=${secondaryProviders.id}` }; render(routerWrapper(renderWrapper())); expect(window.location.href).toEqual(getSiteConfig().lmsBaseUrl + secondaryProviders.registerUrl); @@ -320,7 +319,7 @@ describe('ThirdPartyAuth', () => { }); delete window.location; - window.location = { href: getSiteConfig().baseUrl.concat(LOGIN_PAGE), search: '?next=/dashboard&tpa_hint=invalid' }; + window.location = { href: getSiteConfig().baseUrl.concat('/', loginPath), search: '?next=/dashboard&tpa_hint=invalid' }; const { container } = render(routerWrapper(renderWrapper())); const providerButton = container.querySelector(`button#${ssoProvider.id} span#provider-name`); diff --git a/src/register/data/api.test.ts b/src/register/data/api.test.ts index 8b6f8a5f3..d6274f07c 100644 --- a/src/register/data/api.test.ts +++ b/src/register/data/api.test.ts @@ -1,4 +1,4 @@ -import { getAuthenticatedHttpClient, getHttpClient, getSiteConfig } from '@openedx/frontend-base'; +import { getAuthenticatedHttpClient, getHttpClient, getSiteConfig, getUrlByRouteRole } from '@openedx/frontend-base'; import * as QueryString from 'query-string'; import { getFieldsValidations, registerNewUserApi } from './api'; @@ -8,6 +8,7 @@ jest.mock('@openedx/frontend-base', () => ({ getSiteConfig: jest.fn(), getAuthenticatedHttpClient: jest.fn(), getHttpClient: jest.fn(), + getUrlByRouteRole: jest.fn(), })); jest.mock('query-string', () => ({ @@ -18,6 +19,7 @@ describe('API Functions', () => { let mockAuthenticatedHttpClient: any; let mockHttpClient: any; let mockGetSiteConfig: any; + let mockGetUrlByRouteRole: any; let mockStringify: any; beforeEach(() => { @@ -28,6 +30,7 @@ describe('API Functions', () => { post: jest.fn(), }; mockGetSiteConfig = getSiteConfig as jest.MockedFunction; + mockGetUrlByRouteRole = getUrlByRouteRole as jest.MockedFunction; mockStringify = QueryString.stringify as jest.MockedFunction; (getAuthenticatedHttpClient as jest.MockedFunction) @@ -39,6 +42,8 @@ describe('API Functions', () => { lmsBaseUrl: 'http://localhost:18000', }); + mockGetUrlByRouteRole.mockReturnValue('http://localhost:18000/dashboard'); + mockStringify.mockImplementation((obj) => Object.keys(obj).map(key => `${key}=${obj[key]}`).join('&')); }); diff --git a/src/register/data/api.ts b/src/register/data/api.ts index 510761f71..588f4e73e 100644 --- a/src/register/data/api.ts +++ b/src/register/data/api.ts @@ -1,6 +1,8 @@ import { getAuthenticatedHttpClient, getHttpClient, getSiteConfig } from '@openedx/frontend-base'; import * as QueryString from 'query-string'; +import { normalizeRedirectUrl } from '../../data/utils'; + const registerNewUserApi = async (registrationInformation) => { const requestConfig = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -14,7 +16,7 @@ const registerNewUserApi = async (registrationInformation) => { }); return { - redirectUrl: data.redirect_url || `${getSiteConfig().lmsBaseUrl}/dashboard`, + redirectUrl: normalizeRedirectUrl(data.redirect_url || ''), success: data.success || false, authenticatedUser: data.authenticated_user, }; diff --git a/src/reset-password/ResetPasswordPage.jsx b/src/reset-password/ResetPasswordPage.jsx index 8e9343d83..23e1647df 100644 --- a/src/reset-password/ResetPasswordPage.jsx +++ b/src/reset-password/ResetPasswordPage.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { getSiteConfig, useIntl } from '@openedx/frontend-base'; +import { getSiteConfig, getUrlByRouteRole, useIntl } from '@openedx/frontend-base'; import { Form, Icon, @@ -22,9 +22,8 @@ import { import messages from './messages'; import ResetPasswordFailure from './ResetPasswordFailure'; import { PasswordField } from '../common-components'; -import { - LETTER_REGEX, LOGIN_PAGE, NUMBER_REGEX, RESET_PAGE, -} from '../data/constants'; +import { loginPath, loginRole, resetPasswordRole } from '../constants'; +import { LETTER_REGEX, NUMBER_REGEX } from '../data/constants'; import { getAllPossibleQueryParams, updatePathWithQueryParams, windowScrollTo } from '../data/utils'; import { RegisterProvider } from '../register/components/RegisterContext'; @@ -177,10 +176,10 @@ const ResetPasswordPageInner = () => { useEffect(() => { if (status === PASSWORD_RESET.INVALID_TOKEN) { - navigate(updatePathWithQueryParams(RESET_PAGE), { state: { status } }); + navigate(updatePathWithQueryParams(getUrlByRouteRole(resetPasswordRole)), { state: { status } }); } if (status === 'success') { - navigate(updatePathWithQueryParams(LOGIN_PAGE), { state: { showResetPasswordSuccessBanner: true } }); + navigate(updatePathWithQueryParams(getUrlByRouteRole(loginRole)), { state: { showResetPasswordSuccessBanner: true } }); } }, [status, navigate]); @@ -196,8 +195,8 @@ const ResetPasswordPageInner = () => { {formatMessage(messages['reset.password.page.title'], { siteName: getSiteConfig().siteName })} - navigate(updatePathWithQueryParams(key))}> - + navigate(updatePathWithQueryParams(getUrlByRouteRole(loginRole)))}> +
diff --git a/src/reset-password/tests/ResetPasswordPage.test.jsx b/src/reset-password/tests/ResetPasswordPage.test.jsx index af3583597..e25bce312 100644 --- a/src/reset-password/tests/ResetPasswordPage.test.jsx +++ b/src/reset-password/tests/ResetPasswordPage.test.jsx @@ -10,7 +10,6 @@ import { MemoryRouter } from 'react-router-dom'; import BaseContainer from '../../base-container'; import { appId } from '../../constants'; -import { LOGIN_PAGE } from '../../data/constants'; import { RegisterProvider } from '../../register/components/RegisterContext'; import ResetPasswordPage from '../ResetPasswordPage'; @@ -40,6 +39,7 @@ jest.mock('@openedx/frontend-base', () => ({ userId: 3, username: 'test-user', })), + getUrlByRouteRole: jest.fn(() => '/mock-url'), })); jest.mock('react-router-dom', () => ({ @@ -329,7 +329,7 @@ describe('ResetPasswordPage', () => { fireEvent.click(resetPasswordButton); await waitFor(() => { - expect(mockedNavigator).toHaveBeenCalledWith(LOGIN_PAGE, { + expect(mockedNavigator).toHaveBeenCalledWith('/mock-url', { state: { showResetPasswordSuccessBanner: true }, }); }); @@ -354,7 +354,7 @@ describe('ResetPasswordPage', () => { const signInTab = screen.getByText('Sign in'); fireEvent.click(signInTab); - expect(mockedNavigator).toHaveBeenCalledWith(LOGIN_PAGE); + expect(mockedNavigator).toHaveBeenCalledWith('/mock-url'); }); it('should handle reset password onError with token_invalid true', async () => { diff --git a/src/routes.jsx b/src/routes.jsx index 9546f5144..a30005621 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -1,5 +1,9 @@ import { EmbeddedRegistrationRoute, NotFoundPage, UnAuthOnlyRoute } from './common-components'; -import { LOGIN_PAGE } from './data/constants'; +import { + confirmPasswordRole, loginPath, loginRole, notFoundPath, + passwordResetConfirmPath, registerEmbeddedPath, registerPath, registerRole, + resetPath, resetPasswordRole, welcomePath, welcomeRole, +} from './constants'; import { ForgotPasswordPage } from './forgot-password'; import Logistration from './logistration/Logistration'; import { ProgressiveProfiling } from './progressive-profiling'; @@ -16,58 +20,58 @@ const routes = [ }, children: [ { - path: 'register-embedded', + path: registerEmbeddedPath, element: ( ), }, { - path: 'login', + path: loginPath, handle: { - role: 'org.openedx.frontend.role.login', + role: loginRole, }, element: ( - + ), }, { - path: 'register', + path: registerPath, handle: { - role: 'org.openedx.frontend.role.register', + role: registerRole, }, element: ( ), }, { - path: 'reset', + path: resetPath, handle: { - role: 'org.openedx.frontend.role.resetPassword', + role: resetPasswordRole, }, element: ( ), }, { - path: 'password_reset_confirm/:token', + path: `${passwordResetConfirmPath}/:token`, handle: { - role: 'org.openedx.frontend.role.confirmPassword', + role: confirmPasswordRole, }, element: ( ), }, { - path: 'welcome', + path: welcomePath, handle: { - role: 'org.openedx.frontend.role.welcome', + role: welcomeRole, }, element: ( ), }, { - path: 'notfound', + path: notFoundPath, element: ( ),