Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@fortawesome/free-brands-svg-icons": "6.5.2",
"@fortawesome/free-solid-svg-icons": "6.5.2",
"@fortawesome/react-fontawesome": "0.2.2",
"@reduxjs/toolkit": "^2.2.6",
"@sentry/react": "8.16.0",
"@svgr/webpack": "8.1.0",
"@swc/core": "1.6.13",
Expand All @@ -26,6 +27,7 @@
"react": "18.3.1",
"react-app-polyfill": "3.0.0",
"react-dom": "18.3.1",
"react-redux": "^9.1.2",
"react-router-dom": "6.24.1",
"react-spinners": "0.14.1",
"react-transition-group": "4.4.5",
Expand Down
3 changes: 3 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,6 +53,7 @@ function App(): JSX.Element {
return (
<div>
<Global styles={globalStyles}/>
<AuthorizationSplash/>
<TransitionGroup>
<CSSTransition key={location.pathname} classNames="fade" timeout={300}>
<BrowserRouter>
Expand Down
34 changes: 31 additions & 3 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -32,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."
}

Expand Down Expand Up @@ -94,25 +99,40 @@ 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
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);
Expand All @@ -133,6 +153,8 @@ export async function getDiscordCode(scopes: OAuthScopes[], disableFunction?: (d
if (message.isTrusted) {
windowRef?.close();

formsStore.dispatch(finishAuthorizing());

clearInterval(interval);

// State integrity check
Expand Down Expand Up @@ -246,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);
Expand Down
42 changes: 42 additions & 0 deletions src/components/AuthorizationSplash.tsx
Original file line number Diff line number Diff line change
@@ -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<RootState, boolean>(state => state.authorization.authorizing);

const background = `rgba(0, 0, 0, ${authorizing ? "0.90" : "0"})`;

return <div css={css`
${splashStyles}
background-color: ${background};
opacity: ${authorizing ? "1" : "0"};
z-index: ${authorizing ? "10" : "-10"};
`}>
<div css={spacer}/>
<div css={innerText}>
<h1 css={{fontSize: "3em"}}>Authorization in progress</h1>
<h2>Login with Discord in the opened window and return to this tab once complete.</h2>
</div>
</div>;
}

export default AuthorizationSplash;
6 changes: 4 additions & 2 deletions src/components/OAuth2Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,10 @@ async function login(props: OAuth2ButtonProps, errorDialog: React.RefObject<HTML
}

// Propagate to sentry
reason.Error.stack = new Error(`OAuth: ${reason.Message}`).stack + "\n" + reason.Error.stack;
throw reason.Error;
if (reason.Error) {
reason.Error.stack = new Error(`OAuth: ${reason.Message}`).stack + "\n" + reason.Error.stack;
throw reason.Error;
}
});

if (checkScopes(props.scopes) && props.rerender) {
Expand Down
7 changes: 6 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -71,7 +74,9 @@ root.render(
console.log(err);
}}
>
<App/>
<Provider store={formsStore}>
<App/>
</Provider>
</Sentry.ErrorBoundary>
</React.StrictMode>
);
Expand Down
20 changes: 20 additions & 0 deletions src/slices/authorization.ts
Original file line number Diff line number Diff line change
@@ -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;
21 changes: 21 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
@@ -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<RootState>) => {
return configureStore({
reducer: rootReducer,
preloadedState
});
};

const formsStore = setupStore();

export default formsStore;

export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
5 changes: 3 additions & 2 deletions src/tests/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<App/>);
const {container} = renderWithProviders(<App/>);
await waitFor(() => {
expect(container).toBeInTheDocument();
});
Expand Down
62 changes: 62 additions & 0 deletions src/tests/components/AuthorizationSplash.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AuthorizationSplash />);
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(<AuthorizationSplash />, {
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(<AuthorizationSplash />, {
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");
}
});
36 changes: 36 additions & 0 deletions src/tests/utils.tsx
Original file line number Diff line number Diff line change
@@ -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<RenderOptions, "queries"> {
preloadedState?: Partial<RootState>
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) => (
<Provider store={store}>{children}</Provider>
);

// Return an object with the store and all of RTL"s query functions
return {
store,
...render(ui, { wrapper: Wrapper, ...renderOptions })
};
}
Loading