Skip to content

Commit b3bd8a0

Browse files
authored
Merge pull request #1127 from isaacphysics/handle-session-expiry
Handle session expiry
2 parents 514e419 + a79d68b commit b3bd8a0

File tree

11 files changed

+105
-15
lines changed

11 files changed

+105
-15
lines changed

src/IsaacAppTypes.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export type Action =
3939
| {type: ACTION_TYPE.USER_SNAPSHOT_PARTIAL_UPDATE; userSnapshot: UserSnapshot}
4040

4141
| {type: ACTION_TYPE.CURRENT_USER_REQUEST}
42-
| {type: ACTION_TYPE.CURRENT_USER_RESPONSE_SUCCESS; user: Immutable<ApiTypes.RegisteredUserDTO>}
42+
| {type: ACTION_TYPE.CURRENT_USER_RESPONSE_SUCCESS; user: Immutable<LoggedInUser>}
4343
| {type: ACTION_TYPE.CURRENT_USER_RESPONSE_FAILURE}
4444
| {type: ACTION_TYPE.USER_DETAILS_UPDATE_REQUEST}
4545
| {type: ACTION_TYPE.USER_DETAILS_UPDATE_RESPONSE_SUCCESS; user: Immutable<ApiTypes.RegisteredUserDTO>}
@@ -271,7 +271,7 @@ export interface CanAttemptQuestionTypeDTO {
271271
remainingAttempts?: number;
272272
}
273273

274-
export type LoggedInUser = {loggedIn: true} & ApiTypes.RegisteredUserDTO;
274+
export type LoggedInUser = {loggedIn: true, sessionExpiry?: number} & ApiTypes.RegisteredUserDTO;
275275
export type PotentialUser = LoggedInUser | {loggedIn: false; requesting?: boolean;};
276276

277277
export interface ValidationUser extends ApiTypes.RegisteredUserDTO {

src/app/components/navigation/IsaacApp.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import {MyGameboards} from "../pages/MyGameboards";
7979
import {GameboardFilter} from "../pages/GameboardFilter";
8080
import {ScrollToTop} from "../site/ScrollToTop";
8181
import {QuestionFinder} from "../pages/QuestionFinder";
82+
import {SessionCookieExpired} from "../pages/SessionCookieExpired";
8283

8384
const ContentEmails = lazy(() => import('../pages/ContentEmails'));
8485
const MyProgress = lazy(() => import('../pages/MyProgress'));
@@ -155,6 +156,7 @@ export const IsaacApp = () => {
155156
{/* Errors; these paths work but aren't really used */}
156157
<Route exact path={serverError ? undefined : "/error"} component={ServerError} />
157158
<Route exact path={goneAwayError ? undefined : "/error_stale"} component={SessionExpired} />
159+
<Route exact path={"/error_expired"} component={SessionCookieExpired} />
158160
<TrackedRoute exact path={"/auth_error"} component={AuthError} />
159161
<TrackedRoute exact path={"/consistency-error"} component={ConsistencyError} />
160162

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import React, {useEffect} from "react";
2+
import {Button, Container} from "reactstrap";
3+
import {TitleAndBreadcrumb} from "../elements/TitleAndBreadcrumb";
4+
import {SITE_TITLE, trackEvent} from "../../services";
5+
6+
export const SessionCookieExpired = () => {
7+
useEffect(() => {
8+
trackEvent("exception", { props: { description: `cookie_expired`, fatal: true } });
9+
}, []);
10+
11+
return <Container>
12+
<div>
13+
<TitleAndBreadcrumb breadcrumbTitleOverride="Session expired" currentPageTitle="Your session has expired"/>
14+
<p className="pb-2">{`Sorry, your ${SITE_TITLE} session has expired. Please log in again to continue.`}</p>
15+
<Button color="primary" href="/login">Back to login</Button>
16+
</div>
17+
</Container>;
18+
};

src/app/services/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ export enum ACTION_TYPE {
171171
AUTHENTICATION_HANDLE_CALLBACK = "AUTHENTICATION_HANDLE_CALLBACK",
172172

173173
USER_CONSISTENCY_ERROR = "USER_CONSISTENCY_ERROR",
174+
USER_SESSION_EXPIRED = "USER_SESSION_EXPIRED",
174175

175176
GROUP_GET_MEMBERSHIPS_REQUEST = "GROUP_GET_MEMBERSHIP_REQUEST",
176177
GROUP_GET_MEMBERSHIPS_RESPONSE_SUCCESS = "GROUP_GET_MEMBERSHIP_RESPONSE_SUCCESS",

src/app/state/actions/index.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,10 @@ export const requestCurrentUser = () => async (dispatch: Dispatch<Action>) => {
190190
dispatch(getUserPreferences() as any)
191191
]);
192192
}
193-
dispatch({type: ACTION_TYPE.CURRENT_USER_RESPONSE_SUCCESS, user: currentUser.data});
193+
const expiry = currentUser.headers["x-session-expires"] ?
194+
Date.parse(currentUser.headers["x-session-expires"]) : undefined;
195+
dispatch({type: ACTION_TYPE.CURRENT_USER_RESPONSE_SUCCESS,
196+
user: {loggedIn: true, sessionExpiry: expiry, ...currentUser.data}});
194197
} catch (e) {
195198
dispatch({type: ACTION_TYPE.CURRENT_USER_RESPONSE_FAILURE});
196199
}
@@ -395,11 +398,8 @@ export const logInUser = (provider: AuthenticationProvider, credentials: Credent
395398
return;
396399
}
397400
}
398-
// Request user preferences, as we do in the requestCurrentUser action:
399-
await Promise.all([
400-
dispatch(getUserAuthSettings() as any),
401-
dispatch(getUserPreferences() as any)
402-
]);
401+
// requestCurrentUser gives us extra information like auth settings, preferences and time until session expiry
402+
dispatch(requestCurrentUser() as any);
403403
dispatch({type: ACTION_TYPE.USER_LOG_IN_RESPONSE_SUCCESS, user: result.data});
404404
history.replace(persistence.pop(KEY.AFTER_AUTH_PATH) || "/");
405405
} catch (e: any) {

src/app/state/middleware/userConsistencyChecker.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {AnyAction, Dispatch, Middleware, MiddlewareAPI} from "redux";
1+
import {Dispatch, Middleware, MiddlewareAPI} from "redux";
22
import {RegisteredUserDTO} from "../../../IsaacApiTypes";
33
import {ACTION_TYPE, isDefined} from "../../services";
44
import {redirectTo, getUserId, logAction, setUserId, AppDispatch} from "../index";
@@ -23,6 +23,8 @@ const checkUserConsistency = (middleware: MiddlewareAPI) => {
2323
dispatch(logAction({type: "USER_CONSISTENCY_WARNING_SHOWN", userAgent: navigator.userAgent}));
2424
// Mark error after this check has finished, else the error will be snuffed by the error reducer.
2525
window.setTimeout(() => middleware.dispatch({type: ACTION_TYPE.USER_CONSISTENCY_ERROR}));
26+
} else if (state?.user?.sessionExpiry && state.user.sessionExpiry - Date.now() <= 0) {
27+
window.setTimeout(() => middleware.dispatch({type: ACTION_TYPE.USER_SESSION_EXPIRED}));
2628
} else {
2729
scheduleNextCheck(middleware);
2830
}
@@ -62,6 +64,9 @@ export const userConsistencyCheckerMiddleware: Middleware = (api: MiddlewareAPI)
6264
redirect = "/consistency-error";
6365
clearCurrentUser();
6466
break;
67+
case ACTION_TYPE.USER_SESSION_EXPIRED:
68+
redirect = "/error_expired";
69+
break;
6570
case ACTION_TYPE.USER_LOG_OUT_RESPONSE_SUCCESS:
6671
case ACTION_TYPE.USER_LOG_OUT_EVERYWHERE_RESPONSE_SUCCESS:
6772
redirect = "/";

src/app/state/slices/user.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const userSlice = createSlice({
3131
() => ({loggedIn: false, requesting: true}),
3232
).addMatcher(
3333
loggedInMatcher,
34-
(_, action) => ({loggedIn: true, ...action.user}),
34+
(_, action) => ({sessionExpiry: undefined, loggedIn: true, ...action.user}),
3535
).addMatcher(
3636
loggedOutMatcher,
3737
() => ({loggedIn: false}),

src/test/dateUtils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export const DAYS_AGO = (date: Date = new Date(NOW), delta_days: number, roundDo
1616
return date.valueOf();
1717
};
1818

19-
export const SOME_FIXED_FUTURE_DATE = Date.parse('15 Jan 2050 12:00:00 GMT');
19+
export const SOME_FIXED_FUTURE_DATE_AS_STRING = '15 Jan 2050 12:00:00 GMT';
20+
export const SOME_FIXED_FUTURE_DATE = Date.parse(SOME_FIXED_FUTURE_DATE_AS_STRING);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {navigateToMyAccount, renderTestEnvironment} from "../testUtils";
2+
import {NOW, SOME_FIXED_FUTURE_DATE} from "../dateUtils";
3+
import {redirectTo} from "../../app/state";
4+
import * as Actions from "../../app/state/actions";
5+
6+
7+
describe("SessionExpired", () => {
8+
it('should redirect to session expired login page when session has expired', async () => {
9+
// Arrange
10+
// Annoyingly, the hard-redirect we use is not implemented in JSDOM/RTL, so we need to mock it out.
11+
jest.spyOn(Actions, "redirectTo");
12+
// @ts-ignore
13+
redirectTo.mockImplementation(() => true);
14+
15+
// Set the session expiry to be now
16+
renderTestEnvironment({role: "STUDENT", sessionExpires: new Date(NOW).toUTCString()});
17+
18+
// Navigate to My Account (any page will do)
19+
await navigateToMyAccount();
20+
21+
// Act
22+
// Wait for the user consistency check to notice
23+
await new Promise((r) => setTimeout(r, 1500));
24+
25+
// Assert
26+
// Check we were redirected to the login page. Ideally we'd check the page itself, but this is the best we can
27+
// do for the reasons described above.
28+
expect(redirectTo).toHaveBeenLastCalledWith("/error_expired");
29+
30+
// Teardown
31+
// @ts-ignore
32+
redirectTo.mockRestore();
33+
});
34+
35+
it('should not redirect when session expiry has not passed', async () => {
36+
// Arrange
37+
jest.spyOn(Actions, "redirectTo");
38+
// @ts-ignore
39+
redirectTo.mockImplementation(() => true);
40+
41+
// Set the session expiry to in the future
42+
renderTestEnvironment({role: "STUDENT", sessionExpires: new Date(SOME_FIXED_FUTURE_DATE).toUTCString()});
43+
44+
// Navigate to My Account (any page will do)
45+
await navigateToMyAccount();
46+
47+
// Act
48+
// Wait for the user consistency check to notice, if it's going to
49+
await new Promise((r) => setTimeout(r, 1500));
50+
51+
// Assert
52+
// We should still be where we were.
53+
expect(window.location.pathname).toEqual("/account");
54+
expect(redirectTo).not.toHaveBeenLastCalledWith("/error_expired");
55+
});
56+
});

src/test/state/actions.test.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import {ACTION_TYPE} from "../../app/services";
2222
import {Action} from "../../IsaacAppTypes";
2323
import {jest} from "@jest/globals";
24+
import {SOME_FIXED_FUTURE_DATE_AS_STRING} from "../dateUtils";
2425

2526
const mockStore = configureMockStore([thunk, ...middleware]);
2627
const axiosMock = new MockAdapter(endpoint);
@@ -50,7 +51,8 @@ describe("requestCurrentUser action", () => {
5051
const userAuthSettings = userAuthenticationSettings[dameShirley.id as number];
5152
const userPreferences = userPreferencesSettings[dameShirley.id as number];
5253

53-
axiosMock.onGet(`/users/current_user`).replyOnce(200, dameShirley);
54+
axiosMock.onGet(`/users/current_user`).replyOnce(200, dameShirley,
55+
{"x-session-expires": SOME_FIXED_FUTURE_DATE_AS_STRING});
5456
axiosMock.onGet(`/auth/user_authentication_settings`).replyOnce(200, userAuthSettings);
5557
axiosMock.onGet(`/users/user_preferences`).replyOnce(200, userPreferences);
5658

@@ -63,7 +65,9 @@ describe("requestCurrentUser action", () => {
6365
{type: ACTION_TYPE.USER_PREFERENCES_REQUEST},
6466
{type: ACTION_TYPE.USER_PREFERENCES_RESPONSE_SUCCESS, userPreferences}
6567
];
66-
const expectedFinalActions = [{type: ACTION_TYPE.CURRENT_USER_RESPONSE_SUCCESS, user: dameShirley}];
68+
const expectedFinalActions = [
69+
{type: ACTION_TYPE.CURRENT_USER_RESPONSE_SUCCESS, user: {...dameShirley, loggedIn: true, sessionExpiry: 2525860800000}}
70+
];
6771

6872
const actualActions = store.getActions();
6973

src/test/testUtils.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import React from "react";
1212
import {MemoryRouter} from "react-router";
1313
import {screen, within} from "@testing-library/react";
1414
import userEvent from "@testing-library/user-event";
15+
import {SOME_FIXED_FUTURE_DATE_AS_STRING} from "./dateUtils";
1516

1617
export function paramsToObject(entries: URLSearchParams): {[key: string]: string} {
1718
const result: {[key: string]: string} = {};
@@ -28,6 +29,7 @@ export const augmentErrorMessage = (message?: string) => (e: Error) => {
2829
interface RenderTestEnvironmentOptions {
2930
role?: UserRole | "ANONYMOUS";
3031
modifyUser?: (u: typeof mockUser) => typeof mockUser;
32+
sessionExpires?: string;
3133
PageComponent?: React.FC<any>;
3234
initalRouteEntries?: string[];
3335
extraEndpoints?: RestHandler<any>[];
@@ -43,7 +45,7 @@ interface RenderTestEnvironmentOptions {
4345
// When called, the Redux store will be cleaned completely, and other the MSW server handlers will be reset to
4446
// defaults (those in handlers.ts).
4547
export const renderTestEnvironment = (options?: RenderTestEnvironmentOptions) => {
46-
const {role, modifyUser, PageComponent, initalRouteEntries, extraEndpoints} = options ?? {};
48+
const {role, modifyUser, sessionExpires, PageComponent, initalRouteEntries, extraEndpoints} = options ?? {};
4749
store.dispatch({type: ACTION_TYPE.USER_LOG_OUT_RESPONSE_SUCCESS});
4850
store.dispatch(isaacApi.util.resetApiState());
4951
server.resetHandlers();
@@ -66,7 +68,8 @@ export const renderTestEnvironment = (options?: RenderTestEnvironmentOptions) =>
6668
});
6769
return res(
6870
ctx.status(200),
69-
ctx.json(modifyUser ? modifyUser(userWithRole) : userWithRole)
71+
ctx.json(modifyUser ? modifyUser(userWithRole) : userWithRole),
72+
ctx.set("x-session-expires", sessionExpires ?? SOME_FIXED_FUTURE_DATE_AS_STRING)
7073
);
7174
}),
7275
);

0 commit comments

Comments
 (0)