fix: redirect to api /signin from client /settings (#64398)

This commit is contained in:
Oliver Eyton-Williams
2025-12-14 06:59:24 +01:00
committed by GitHub
parent 1146bc23f8
commit ac3a31e920
7 changed files with 61 additions and 74 deletions

View File

@@ -1,60 +1,55 @@
/* eslint-disable */
// @ts-nocheck Likely need to not use ShallowRenderer
import React from 'react'; import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow'; import { render } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi, beforeAll } from 'vitest';
import { Provider } from 'react-redux';
import envData from '../../config/env.json'; import envData from '../../config/env.json';
import { ShowSettings } from './show-settings'; import ShowSettings from './show-settings';
vi.mock('../analytics'); import { createStore } from '../redux/create-store';
vi.mock('@growthbook/growthbook-react', () => ({ import { initialState } from '../redux';
useFeatureIsOn: () => false
}));
const { apiLocation } = envData as Record<string, string>; vi.mock('../utils/get-words');
const { apiLocation } = envData;
describe('<ShowSettings />', () => { describe('<ShowSettings />', () => {
it('renders to the DOM when user is logged in', () => { beforeAll(() => {
const shallow = new ShallowRenderer(); // Location is not writable normally, so we have to delete and recreate
shallow.render(<ShowSettings {...loggedInProps} />); // https://github.com/jestjs/jest/issues/890#issuecomment-682286025
expect(navigate).toHaveBeenCalledTimes(0); const location = window.location as string & Location;
const result = shallow.getRenderOutput(); // @ts-expect-error TS is warning us that this breaks the type of
expect(result.type.toString()).toBe('Symbol(react.fragment)'); // window.location, since it is not optional, but we are replacing it with
// Renders Helmet component rather than Loader // an object of the same type, it is safe to ignore.
expect(result.props.children[0].props.title).toEqual( delete global.window.location;
'buttons.settings | freeCodeCamp.org' global.window.location = Object.assign({}, location);
});
it('does not navigate if already signed in', () => {
const store = createStore({
app: { ...initialState, user: { sessionUser: 'anything truthy' } }
});
const spy = vi.spyOn(window.location, 'href', 'set');
render(
<Provider store={store}>
<ShowSettings />
</Provider>
); );
expect(spy).toHaveBeenCalledTimes(0);
}); });
it('redirects to sign in page when user is not logged in', () => { it('redirects to sign in page when user is not logged in', () => {
const shallow = new ShallowRenderer(); const store = createStore({
shallow.render(<ShowSettings {...loggedOutProps} />); app: { ...initialState, user: { sessionUser: null } }
expect(navigate).toHaveBeenCalledTimes(1); });
expect(navigate).toHaveBeenCalledWith(`${apiLocation}/signin`); const spy = vi.spyOn(window.location, 'href', 'set');
const result = shallow.getRenderOutput();
// Renders Loader rather than ShowSettings render(
expect(result.type.displayName).toBe('Loader'); <Provider store={store}>
<ShowSettings />
</Provider>
);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(`${apiLocation}/signin`);
}); });
}); });
const navigate = vi.fn();
const loggedInProps = {
createFlashMessage: vi.fn(),
hardGoTo: vi.fn(),
isSignedIn: true,
navigate: navigate,
showLoading: false,
submitNewAbout: vi.fn(),
toggleTheme: vi.fn(),
updateSocials: vi.fn(),
updateIsHonest: vi.fn(),
updatePortfolio: vi.fn(),
updateQuincyEmail: vi.fn(),
user: {
about: '',
completedChallenges: []
},
verifyCert: vi.fn()
};
const loggedOutProps = { ...loggedInProps };
loggedOutProps.isSignedIn = false;

View File

@@ -1,4 +1,4 @@
import React, { useRef, useEffect } from 'react'; import React, { useEffect } from 'react';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@@ -23,7 +23,6 @@ import { hardGoTo as navigate } from '../redux/actions';
import { import {
signInLoadingSelector, signInLoadingSelector,
userSelector, userSelector,
isSignedInSelector,
userTokenSelector userTokenSelector
} from '../redux/selectors'; } from '../redux/selectors';
import type { User } from '../redux/prop-types'; import type { User } from '../redux/prop-types';
@@ -44,7 +43,6 @@ const { apiLocation } = envData;
// TODO: update types for actions // TODO: update types for actions
type ShowSettingsProps = { type ShowSettingsProps = {
createFlashMessage: typeof createFlashMessage; createFlashMessage: typeof createFlashMessage;
isSignedIn: boolean;
navigate: (location: string) => void; navigate: (location: string) => void;
showLoading: boolean; showLoading: boolean;
toggleSoundMode: (sound: boolean) => void; toggleSoundMode: (sound: boolean) => void;
@@ -61,17 +59,10 @@ type ShowSettingsProps = {
const mapStateToProps = createSelector( const mapStateToProps = createSelector(
signInLoadingSelector, signInLoadingSelector,
userSelector, userSelector,
isSignedInSelector,
userTokenSelector, userTokenSelector,
( (showLoading: boolean, user: User | null, userToken: string | null) => ({
showLoading: boolean,
user: User | null,
isSignedIn,
userToken: string | null
) => ({
showLoading, showLoading,
user, user,
isSignedIn,
userToken userToken
}) })
); );
@@ -94,7 +85,6 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
createFlashMessage, createFlashMessage,
isSignedIn,
toggleSoundMode, toggleSoundMode,
toggleKeyboardShortcuts, toggleKeyboardShortcuts,
resetEditorLayout, resetEditorLayout,
@@ -107,8 +97,6 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
userToken userToken
} = props; } = props;
const isSignedInRef = useRef(isSignedIn);
const handleHashChange = () => { const handleHashChange = () => {
const id = window.location.hash.replace('#', ''); const id = window.location.hash.replace('#', '');
if (id) { if (id) {
@@ -127,12 +115,11 @@ export function ShowSettings(props: ShowSettingsProps): JSX.Element {
return () => window.removeEventListener('hashchange', handleHashChange); return () => window.removeEventListener('hashchange', handleHashChange);
}, []); }, []);
if (showLoading || !user) { useEffect(() => {
return <Loader fullScreen={true} />; if (!user) navigate(`${apiLocation}/signin`);
} }, [user, navigate]);
if (!isSignedInRef.current) { if (showLoading || !user) {
navigate(`${apiLocation}/signin`);
return <Loader fullScreen={true} />; return <Loader fullScreen={true} />;
} }

View File

@@ -47,7 +47,6 @@ export const createStore = (preloadedState = {}) => {
preloadedState preloadedState
}); });
sagaMiddleware.run(rootSaga); sagaMiddleware.run(rootSaga);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
epicMiddleware.run(rootEpic); epicMiddleware.run(rootEpic);
if (module.hot) { if (module.hot) {

View File

@@ -3,11 +3,12 @@ import { tap, ignoreElements } from 'rxjs/operators';
import { actionTypes } from './action-types'; import { actionTypes } from './action-types';
export default function hardGoToEpic(action$, _, { location }) { // The third argument contains dependencies, see createEpicMiddleware
export default function hardGoToEpic(action$, _, { window }) {
return action$.pipe( return action$.pipe(
ofType(actionTypes.hardGoTo), ofType(actionTypes.hardGoTo),
tap(({ payload }) => { tap(({ payload }) => {
location.href = payload; window.location.href = payload;
}), }),
ignoreElements() ignoreElements()
); );

View File

@@ -49,7 +49,8 @@ export const defaultDonationFormState = {
} }
}; };
const initialState = { // exported for testing purposes.
export const initialState = {
isRandomCompletionThreshold: false, isRandomCompletionThreshold: false,
donatableSectionRecentlyCompleted: null, donatableSectionRecentlyCompleted: null,
currentChallengeId: store.get(CURRENT_CHALLENGE_KEY), currentChallengeId: store.get(CURRENT_CHALLENGE_KEY),

View File

@@ -3,7 +3,6 @@ import { combineEpics } from 'redux-observable';
import { epics as challengeEpics } from '../templates/Challenges/redux'; import { epics as challengeEpics } from '../templates/Challenges/redux';
import { epics as appEpics } from '.'; import { epics as appEpics } from '.';
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const rootEpic = combineEpics(...appEpics, ...challengeEpics); const rootEpic = combineEpics(...appEpics, ...challengeEpics);
export default rootEpic; export default rootEpic;

View File

@@ -1,4 +1,5 @@
import { navigate } from 'gatsby'; import { withPrefix } from 'gatsby';
import { navigate } from '@gatsbyjs/reach-router';
import { call, put, take, takeEvery } from 'redux-saga/effects'; import { call, put, take, takeEvery } from 'redux-saga/effects';
import { createFlashMessage } from '../../components/Flash/redux'; import { createFlashMessage } from '../../components/Flash/redux';
@@ -17,9 +18,13 @@ function* deleteAccountSaga() {
message: FlashMessages.AccountDeleted message: FlashMessages.AccountDeleted
}) })
); );
// navigate before signing out, since /settings will attempt to sign users
// back in. Using reach-router's navigate because gatsby's resolves after
// the call. This would allow resetUserData to take place while the user is
// still on /settings.
yield call(navigate, withPrefix('/learn'));
// remove current user information from application state // remove current user information from application state
yield put(resetUserData()); yield put(resetUserData());
yield call(navigate, '/learn');
} catch (e) { } catch (e) {
yield put(deleteAccountError(e)); yield put(deleteAccountError(e));
} }