Skip to content

Commit 0323313

Browse files
arbrandesclaude
andcommitted
feat: handle relative next param locally after login/registration
When a user navigates to a protected SPA route while logged out, the authenticatedLoader redirects to /authn/login?next=%2Fsome-path. Previously, this relative next param was sent to the LMS backend, which resolved it against itself and returned an absolute LMS URL — causing a full page navigation away from the SPA shell. Now, when next starts with /, it is stripped from the API payload and handled locally: after login/registration succeeds, fetchAuthenticatedUser refreshes the auth cache so authenticatedLoader allows the target route, RedirectLogistration uses <Navigate> for SPA navigation, and hydrateAuthenticatedUser runs in the background to populate the full user profile (avatar, etc.) in the header. Also applies the same relative-path SPA navigation pattern to ProgressiveProfilingPageModal and replaces the hardcoded LMS dashboard fallback in the registration API with getUrlByRouteRole for consistency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c0cf462 commit 0323313

7 files changed

Lines changed: 51 additions & 11 deletions

File tree

src/common-components/RedirectLogistration.jsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ const RedirectLogistration = (props) => {
5959
);
6060
}
6161

62+
if (finalRedirectUrl.startsWith('/')) {
63+
return <Navigate to={finalRedirectUrl} replace />;
64+
}
6265
window.location.href = finalRedirectUrl;
6366
}
6467

src/login/LoginPage.jsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useEffect, useMemo, useState } from 'react';
22

33
import {
4-
getSiteConfig, sendPageEvent, sendTrackEvent, useIntl
4+
fetchAuthenticatedUser, hydrateAuthenticatedUser, getSiteConfig, sendPageEvent, sendTrackEvent, useIntl,
55
} from '@openedx/frontend-base';
66
import { Form, StatefulButton } from '@openedx/paragon';
77
import PropTypes from 'prop-types';
@@ -65,8 +65,16 @@ const LoginPage = ({
6565
context: {},
6666
});
6767
const { mutate: loginUser, isPending: isLoggingIn } = useLogin({
68-
onSuccess: (data) => {
69-
setLoginResult({ success: true, redirectUrl: data.redirectUrl || '' });
68+
onSuccess: async (data) => {
69+
if (localNextPath) {
70+
await fetchAuthenticatedUser({ forceRefresh: true });
71+
setLoginResult({ success: true, redirectUrl: localNextPath });
72+
// Hydrate in the background — publishes AUTHENTICATED_USER_CHANGED after
73+
// SPA navigation, so the header picks up the full user profile (avatar, etc.)
74+
hydrateAuthenticatedUser();
75+
} else {
76+
setLoginResult({ success: true, redirectUrl: data.redirectUrl || '' });
77+
}
7078
},
7179
onError: (formattedError) => {
7280
setErrorCode(prev => ({
@@ -90,6 +98,7 @@ const LoginPage = ({
9098
const { formatMessage } = useIntl();
9199
const activationMsgType = getActivationStatus();
92100
const queryParams = useMemo(() => getAllPossibleQueryParams(), []);
101+
const localNextPath = queryParams.next?.startsWith('/') ? queryParams.next : null;
93102

94103
const tpaHint = useMemo(() => getTpaHint(), []);
95104
const params = { ...queryParams };
@@ -173,6 +182,9 @@ const LoginPage = ({
173182
password: formData.password,
174183
...queryParams,
175184
};
185+
if (localNextPath) {
186+
delete payload.next;
187+
}
176188
loginUser(payload);
177189
};
178190

src/progressive-profiling/ProgressiveProfilingPageModal.jsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
import { getSiteConfig, useIntl } from '@openedx/frontend-base';
22
import { ActionRow, Button, ModalDialog } from '@openedx/paragon';
33
import PropTypes from 'prop-types';
4+
import { useNavigate } from 'react-router-dom';
45

56
import messages from './messages';
67

78
const ProgressiveProfilingPageModal = (props) => {
89
const { formatMessage } = useIntl();
10+
const navigate = useNavigate();
911
const { isOpen, redirectUrl } = props;
1012
const platformName = getSiteConfig().siteName;
1113

1214
const handleSubmit = (e) => {
1315
e.preventDefault();
14-
window.location.href = redirectUrl;
16+
if (redirectUrl.startsWith('/')) {
17+
navigate(redirectUrl);
18+
} else {
19+
window.location.href = redirectUrl;
20+
}
1521
};
1622

1723
return (

src/register/RegistrationPage.jsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { useEffect, useMemo, useState } from 'react';
22

33
import {
4+
fetchAuthenticatedUser,
5+
hydrateAuthenticatedUser,
46
useAppConfig,
57
getSiteConfig,
68
sendPageEvent, sendTrackEvent,
@@ -108,8 +110,16 @@ const RegistrationPage = (props) => {
108110

109111
const backendRegistrationError = registrationError;
110112
const registrationMutation = useRegistration({
111-
onSuccess: (data) => {
112-
setRegistrationResult(data);
113+
onSuccess: async (data) => {
114+
if (localNextPath) {
115+
await fetchAuthenticatedUser({ forceRefresh: true });
116+
setRegistrationResult({ ...data, redirectUrl: localNextPath });
117+
// Hydrate in the background — publishes AUTHENTICATED_USER_CHANGED after
118+
// SPA navigation, so the header picks up the full user profile (avatar, etc.)
119+
hydrateAuthenticatedUser();
120+
} else {
121+
setRegistrationResult(data);
122+
}
113123
setRegistrationError({});
114124
},
115125
onError: (errorData) => {
@@ -121,6 +131,7 @@ const RegistrationPage = (props) => {
121131
const registrationErrorCode = registrationError?.errorCode || backendRegistrationError?.errorCode;
122132
const submitState = registrationMutation.isPending ? PENDING_STATE : DEFAULT_STATE;
123133
const queryParams = useMemo(() => getAllPossibleQueryParams(), []);
134+
const localNextPath = queryParams.next?.startsWith('/') ? queryParams.next : null;
124135
const tpaHint = useMemo(() => getTpaHint(), []);
125136
// Initialize form state from local backedUpFormData
126137
const backedUpFormData = registrationFormData;
@@ -292,12 +303,15 @@ const RegistrationPage = (props) => {
292303
}
293304

294305
// Preparing payload for submission
306+
const registrationQueryParams = localNextPath
307+
? Object.fromEntries(Object.entries(queryParams).filter(([key]) => key !== 'next'))
308+
: queryParams;
295309
payload = prepareRegistrationPayload(
296310
payload,
297311
configurableFormFields,
298312
flags.showMarketingEmailOptInCheckbox,
299313
totalRegistrationTime,
300-
queryParams,
314+
registrationQueryParams,
301315
);
302316

303317
// making register call with React Query

src/register/RegistrationPage.test.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,14 +266,14 @@ describe('RegistrationPage', () => {
266266
password: 'password1',
267267
country: 'Pakistan',
268268
total_registration_time: 0,
269-
next: '/course/demo-course-url',
270269
};
271270

272271
const { getByLabelText, container } = render(renderWrapper(<RegistrationPage {...props} />));
273272
populateRequiredFields(getByLabelText, payload);
274273
const button = container.querySelector('button.btn-brand');
275274
fireEvent.click(button);
276275

276+
// Relative `next` param is stripped from the API payload (handled locally via SPA navigation)
277277
expect(mockRegistrationMutation.mutate).toHaveBeenCalledWith({ ...payload, country: 'PK' });
278278
});
279279

src/register/data/api.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getAuthenticatedHttpClient, getHttpClient, getSiteConfig } from '@openedx/frontend-base';
1+
import { getAuthenticatedHttpClient, getHttpClient, getSiteConfig, getUrlByRouteRole } from '@openedx/frontend-base';
22
import * as QueryString from 'query-string';
33

44
import { getFieldsValidations, registerNewUserApi } from './api';
@@ -8,6 +8,7 @@ jest.mock('@openedx/frontend-base', () => ({
88
getSiteConfig: jest.fn(),
99
getAuthenticatedHttpClient: jest.fn(),
1010
getHttpClient: jest.fn(),
11+
getUrlByRouteRole: jest.fn(),
1112
}));
1213

1314
jest.mock('query-string', () => ({
@@ -18,6 +19,7 @@ describe('API Functions', () => {
1819
let mockAuthenticatedHttpClient: any;
1920
let mockHttpClient: any;
2021
let mockGetSiteConfig: any;
22+
let mockGetUrlByRouteRole: any;
2123
let mockStringify: any;
2224

2325
beforeEach(() => {
@@ -28,6 +30,7 @@ describe('API Functions', () => {
2830
post: jest.fn(),
2931
};
3032
mockGetSiteConfig = getSiteConfig as jest.MockedFunction<typeof getSiteConfig>;
33+
mockGetUrlByRouteRole = getUrlByRouteRole as jest.MockedFunction<typeof getUrlByRouteRole>;
3134
mockStringify = QueryString.stringify as jest.MockedFunction<typeof QueryString.stringify>;
3235

3336
(getAuthenticatedHttpClient as jest.MockedFunction<typeof getAuthenticatedHttpClient>)
@@ -39,6 +42,8 @@ describe('API Functions', () => {
3942
lmsBaseUrl: 'http://localhost:18000',
4043
});
4144

45+
mockGetUrlByRouteRole.mockReturnValue('http://localhost:18000/dashboard');
46+
4247
mockStringify.mockImplementation((obj) => Object.keys(obj).map(key => `${key}=${obj[key]}`).join('&'));
4348
});
4449

src/register/data/api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getAuthenticatedHttpClient, getHttpClient, getSiteConfig } from '@openedx/frontend-base';
1+
import { getAuthenticatedHttpClient, getHttpClient, getSiteConfig, getUrlByRouteRole } from '@openedx/frontend-base';
22
import * as QueryString from 'query-string';
33

44
const registerNewUserApi = async (registrationInformation) => {
@@ -14,7 +14,7 @@ const registerNewUserApi = async (registrationInformation) => {
1414
});
1515

1616
return {
17-
redirectUrl: data.redirect_url || `${getSiteConfig().lmsBaseUrl}/dashboard`,
17+
redirectUrl: data.redirect_url || getUrlByRouteRole('org.openedx.frontend.role.dashboard'),
1818
success: data.success || false,
1919
authenticatedUser: data.authenticated_user,
2020
};

0 commit comments

Comments
 (0)