aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/App.tsx3
-rw-r--r--src/api/auth.ts34
-rw-r--r--src/components/AuthorizationSplash.tsx42
-rw-r--r--src/components/OAuth2Button.tsx6
-rw-r--r--src/index.tsx7
-rw-r--r--src/slices/authorization.ts20
-rw-r--r--src/store.ts21
-rw-r--r--src/tests/App.test.tsx5
-rw-r--r--src/tests/components/AuthorizationSplash.test.tsx62
-rw-r--r--src/tests/utils.tsx36
10 files changed, 228 insertions, 8 deletions
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 (
<div>
<Global styles={globalStyles}/>
+ <AuthorizationSplash/>
<TransitionGroup>
<CSSTransition key={location.pathname} classNames="fade" timeout={300}>
<BrowserRouter>
diff --git a/src/api/auth.ts b/src/api/auth.ts
index c9e3634..11baaa6 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.
@@ -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."
}
@@ -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);
@@ -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
@@ -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);
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<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;
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<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) {
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);
}}
>
- <App/>
+ <Provider store={formsStore}>
+ <App/>
+ </Provider>
</Sentry.ErrorBoundary>
</React.StrictMode>
);
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<RootState>) => {
+ return configureStore({
+ reducer: rootReducer,
+ preloadedState
+ });
+};
+
+const formsStore = setupStore();
+
+export default formsStore;
+
+export type RootState = ReturnType<typeof rootReducer>
+export type AppStore = ReturnType<typeof setupStore>
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(<App/>);
+ const {container} = renderWithProviders(<App/>);
await waitFor(() => {
expect(container).toBeInTheDocument();
});
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(<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");
+ }
+});
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<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 })
+ };
+}