From 5c8125a283fbb6c0ffd69b36e43ff655fc7ce8aa Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 9 Jul 2024 20:20:38 +0100 Subject: Add new redux stores and slices for authorization state --- src/slices/authorization.ts | 20 ++++++++++++++++++++ src/store.ts | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 src/slices/authorization.ts create mode 100644 src/store.ts (limited to 'src') diff --git a/src/slices/authorization.ts b/src/slices/authorization.ts new file mode 100644 index 0000000..c70039d --- /dev/null +++ b/src/slices/authorization.ts @@ -0,0 +1,20 @@ +import { createSlice } from "@reduxjs/toolkit"; + +const authorizationSlice = createSlice({ + name: "authorization", + initialState: { + authorizing: false, + }, + reducers: { + startAuthorizing: (state) => { + state.authorizing = true; + }, + finishAuthorizing: (state) => { + state.authorizing = false; + }, + }, +}); + +export const { startAuthorizing, finishAuthorizing } = authorizationSlice.actions; + +export default authorizationSlice.reducer; diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000..1b9807b --- /dev/null +++ b/src/store.ts @@ -0,0 +1,21 @@ +import { combineReducers, configureStore } from "@reduxjs/toolkit"; + +import authorizationReducer from "./slices/authorization"; + +const rootReducer = combineReducers({ + authorization: authorizationReducer +}); + +export const setupStore = (preloadedState?: Partial) => { + return configureStore({ + reducer: rootReducer, + preloadedState + }); +}; + +const formsStore = setupStore(); + +export default formsStore; + +export type RootState = ReturnType +export type AppStore = ReturnType -- cgit v1.2.3 From a735211aec6c8343868aa250d727e13e68072fb2 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 9 Jul 2024 20:21:06 +0100 Subject: Wrap application in Redux Provider with new store --- src/index.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/index.tsx b/src/index.tsx index 10e8c91..a7fdd2a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,6 +5,9 @@ import { createRoot } from "react-dom/client"; import App from "./App"; import * as serviceWorker from "./serviceWorker"; +import formsStore from "./store"; +import { Provider } from "react-redux"; + import * as Sentry from "@sentry/react"; import { @@ -71,7 +74,9 @@ root.render( console.log(err); }} > - + + + ); -- cgit v1.2.3 From 5dac3c06b99f42d2c571e31cbe71f14bae84d614 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 9 Jul 2024 20:21:19 +0100 Subject: Send dispatches to store on authorization state attempt changes --- src/api/auth.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/api/auth.ts b/src/api/auth.ts index c9e3634..2838e4b 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,10 +1,14 @@ import Cookies, { CookieSetOptions } from "universal-cookie"; import { AxiosResponse } from "axios"; +import { startAuthorizing, finishAuthorizing } from "../slices/authorization"; +import formsStore from "../store"; + import APIClient from "./client"; const OAUTH2_CLIENT_ID = process.env.REACT_APP_OAUTH2_CLIENT_ID; const PRODUCTION = process.env.NODE_ENV !== "development"; +const STATE_LENGTH = 64; /** * Authorization result as returned from the backend. @@ -98,21 +102,36 @@ export async function getDiscordCode(scopes: OAuthScopes[], disableFunction?: (d const cleanedScopes = ensureMinimumScopes(scopes, OAuthScopes.Identify); // Generate a new user state - const state = crypto.getRandomValues(new Uint32Array(1))[0]; + const stateBytes = new Uint8Array(STATE_LENGTH); + crypto.getRandomValues(stateBytes); + + let state = ""; + for (let i = 0; i < stateBytes.length; i++) { + state += stateBytes[i].toString(16).padStart(2, "0"); + } const scopeString = encodeURIComponent(cleanedScopes.join(" ")); const redirectURI = encodeURIComponent(document.location.protocol + "//" + document.location.host + "/callback"); + const windowHeight = screen.availHeight; + const windowWidth = screen.availWidth; + const requestHeight = Math.floor(windowHeight * 0.75); + const requestWidth = Math.floor(windowWidth * 0.4); + // Open login window const windowRef = window.open( `https://discord.com/api/oauth2/authorize?client_id=${OAUTH2_CLIENT_ID}&state=${state}&response_type=code&scope=${scopeString}&redirect_uri=${redirectURI}&prompt=consent`, - "Discord_OAuth2" + "_blank", + `popup=true,height=${requestHeight},left=0,top=0,width=${requestWidth}` ); + formsStore.dispatch(startAuthorizing()); + // Clean up on login const interval = setInterval(() => { if (windowRef?.closed) { clearInterval(interval); + formsStore.dispatch(finishAuthorizing()); if (disableFunction) { disableFunction(false); } } }, 500); @@ -133,6 +152,8 @@ export async function getDiscordCode(scopes: OAuthScopes[], disableFunction?: (d if (message.isTrusted) { windowRef?.close(); + formsStore.dispatch(finishAuthorizing()); + clearInterval(interval); // State integrity check -- cgit v1.2.3 From f5024e46fd68c153ba20e094ec1e17012e4ae4fc Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 9 Jul 2024 20:21:39 +0100 Subject: Add AuthorizationSplash to display above content when auth in progress --- src/App.tsx | 3 +++ src/components/AuthorizationSplash.tsx | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 src/components/AuthorizationSplash.tsx (limited to 'src') diff --git a/src/App.tsx b/src/App.tsx index 70e0b11..d622840 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,8 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import { PropagateLoader } from "react-spinners"; +import AuthorizationSplash from "./components/AuthorizationSplash"; + import { CSSTransition, TransitionGroup } from "react-transition-group"; import globalStyles from "./globalStyles"; @@ -51,6 +53,7 @@ function App(): JSX.Element { return (
+ diff --git a/src/components/AuthorizationSplash.tsx b/src/components/AuthorizationSplash.tsx new file mode 100644 index 0000000..6fd211a --- /dev/null +++ b/src/components/AuthorizationSplash.tsx @@ -0,0 +1,42 @@ +/** @jsx jsx */ +import { css, jsx } from "@emotion/react"; +import { useSelector } from "react-redux"; +import { type RootState } from "../store"; + +const splashStyles = css` +position: fixed; +width: 100%; +height: 100%; +top: 0; +transition: background-color 0.5s ease, opacity 0.5s ease; +`; + +const innerText = css` +text-align: center; +vertical-align: middle; +`; + +const spacer = css` +height: 30%; +`; + +function AuthorizationSplash(): JSX.Element { + const authorizing = useSelector(state => state.authorization.authorizing); + + const background = `rgba(0, 0, 0, ${authorizing ? "0.90" : "0"})`; + + return
+
+
+

Authorization in progress

+

Login with Discord in the opened window and return to this tab once complete.

+
+
; +} + +export default AuthorizationSplash; -- cgit v1.2.3 From 0bf31556d7afc359ce0ebb145c962f224e547bd1 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 9 Jul 2024 21:11:13 +0100 Subject: Gracefully handle user cancelled authorization --- src/api/auth.ts | 9 ++++++++- src/components/OAuth2Button.tsx | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/api/auth.ts b/src/api/auth.ts index 2838e4b..11baaa6 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -36,6 +36,7 @@ export enum APIErrorMessages { BackendValidationDev = "Backend could not authorize with Discord, possibly due to being on a preview branch. Please contact the forms team.", BackendUnresponsive = "Unable to reach the backend, please retry, or contact the forms team.", BadResponse = "The server returned a bad response, please contact the forms team.", + AccessRejected = "Authorization was cancelled.", Unknown = "An unknown error occurred, please contact the forms team." } @@ -98,7 +99,7 @@ export function checkScopes(scopes?: OAuthScopes[]): boolean { * @returns {code, cleanedScopes} The discord authorization code and the scopes the code is granted for. * @throws {Error} Indicates that an integrity check failed. */ -export async function getDiscordCode(scopes: OAuthScopes[], disableFunction?: (disable: boolean) => void): Promise<{code: string, cleanedScopes: OAuthScopes[]}> { +export async function getDiscordCode(scopes: OAuthScopes[], disableFunction?: (disable: boolean) => void): Promise<{code: string | null, cleanedScopes: OAuthScopes[]}> { const cleanedScopes = ensureMinimumScopes(scopes, OAuthScopes.Identify); // Generate a new user state @@ -267,6 +268,12 @@ export default async function authorize(scopes: OAuthScopes[] = [], disableFunct if (disableFunction) { disableFunction(true); } await getDiscordCode(scopes, disableFunction).then(async discord_response =>{ + if (!discord_response.code) { + throw { + Message: APIErrorMessages.AccessRejected, + Error: null + }; + } await requestBackendJWT(discord_response.code).then(backend_response => { const options: CookieSetOptions = {sameSite: "strict", secure: PRODUCTION, path: "/", expires: new Date(3000, 1)}; cookies.set(CookieNames.Username, backend_response.username, options); diff --git a/src/components/OAuth2Button.tsx b/src/components/OAuth2Button.tsx index be8d160..67399ee 100644 --- a/src/components/OAuth2Button.tsx +++ b/src/components/OAuth2Button.tsx @@ -55,8 +55,10 @@ async function login(props: OAuth2ButtonProps, errorDialog: React.RefObject Date: Tue, 9 Jul 2024 21:55:24 +0100 Subject: Add new testing utility for rendering with Redux stores --- src/tests/utils.tsx | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/tests/utils.tsx (limited to 'src') diff --git a/src/tests/utils.tsx b/src/tests/utils.tsx new file mode 100644 index 0000000..011291a --- /dev/null +++ b/src/tests/utils.tsx @@ -0,0 +1,36 @@ +/** @jsx jsx */ +import { jsx } from "@emotion/react"; +import { PropsWithChildren } from "react"; +import { render } from "@testing-library/react"; +import type { RenderOptions } from "@testing-library/react"; +import { Provider } from "react-redux"; + +import type { AppStore, RootState } from "../store"; +import { setupStore } from "../store"; + +interface ExtendedRenderOptions extends Omit { + preloadedState?: Partial + store?: AppStore +} + +export function renderWithProviders( + ui: React.ReactElement, + extendedRenderOptions: ExtendedRenderOptions = {} +) { + const { + preloadedState = {}, + // Automatically create a store instance if no store was passed in + store = setupStore(preloadedState), + ...renderOptions + } = extendedRenderOptions; + + const Wrapper = ({ children }: PropsWithChildren) => ( + {children} + ); + + // Return an object with the store and all of RTL"s query functions + return { + store, + ...render(ui, { wrapper: Wrapper, ...renderOptions }) + }; +} -- cgit v1.2.3 From 696b200ff8f0ea4203f0cbd0c4e68aa0fd3de91b Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 9 Jul 2024 21:55:34 +0100 Subject: Update App test to render with Redux store --- src/tests/App.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/tests/App.test.tsx b/src/tests/App.test.tsx index 9ee1ec7..aec6be4 100644 --- a/src/tests/App.test.tsx +++ b/src/tests/App.test.tsx @@ -1,11 +1,12 @@ import React from "react"; -import {act, render, waitFor} from "@testing-library/react"; +import {act, waitFor} from "@testing-library/react"; +import { renderWithProviders } from "./utils"; import App from "../App"; test("renders app to body", async () => { await act(async () => { - const {container} = render(); + const {container} = renderWithProviders(); await waitFor(() => { expect(container).toBeInTheDocument(); }); -- cgit v1.2.3 From e586c951c14d1bac4cabc1f582aca4d12045f179 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 9 Jul 2024 21:55:42 +0100 Subject: Add new test suite for testing authorization splash --- src/tests/components/AuthorizationSplash.test.tsx | 62 +++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/tests/components/AuthorizationSplash.test.tsx (limited to 'src') diff --git a/src/tests/components/AuthorizationSplash.test.tsx b/src/tests/components/AuthorizationSplash.test.tsx new file mode 100644 index 0000000..7f44ba4 --- /dev/null +++ b/src/tests/components/AuthorizationSplash.test.tsx @@ -0,0 +1,62 @@ +/** @jsx jsx */ +import { jsx } from "@emotion/react"; +import { renderWithProviders } from "../utils"; +import AuthorizationSplash from "../../components/AuthorizationSplash"; +import { finishAuthorizing } from "../../slices/authorization"; +import { act } from "@testing-library/react"; + +test("authorization splash is hidden when not authorizing", () => { + const { container } = renderWithProviders(); + const splash = container.firstElementChild; + + expect(splash).not.toBe(null); + + if (splash) { + const style = window.getComputedStyle(splash); + expect(style.opacity).toBe("0"); + } +}); + +test("authorization splash is visible when authorizing state is set", () => { + const { container } = renderWithProviders(, { + preloadedState: { + authorization: { + authorizing: true + } + } + }); + const splash = container.firstElementChild; + + expect(splash).not.toBe(null); + + if (splash) { + const style = window.getComputedStyle(splash); + expect(style.opacity).toBe("1"); + } +}); + +test("test state transitions when authorization completes", () => { + const { store, container } = renderWithProviders(, { + preloadedState: { + authorization: { + authorizing: true + } + } + }); + + const splash = container.firstElementChild; + + expect(splash).not.toBe(null); + + if (splash) { + let style = window.getComputedStyle(splash); + expect(style.opacity).toBe("1"); + + act(() => { + store.dispatch(finishAuthorizing()); + }); + + style = window.getComputedStyle(splash); + expect(style.opacity).toBe("0"); + } +}); -- cgit v1.2.3