From 5f8711833f411fae4b129e46c10a4cda3f3c2f7f Mon Sep 17 00:00:00 2001 From: Michael Fatemi Date: Sat, 10 Apr 2021 16:14:29 -0400 Subject: [PATCH] Add better API, authentication wrapper --- package.json | 1 + src/App.tsx | 5 +- src/api.ts | 5 -- src/api/api.ts | 42 ++++++++++ src/api/error.ts | 5 ++ src/api/utils.ts | 102 +++++++++++++++++++++++ src/components/AuthenticationContext.ts | 19 +++++ src/components/AuthenticationWrapper.tsx | 39 +++++++++ src/components/Authenticator.tsx | 2 +- src/components/Group.tsx | 2 +- src/components/Home.tsx | 2 +- src/components/MyPools.tsx | 2 +- src/components/Pools.tsx | 2 +- src/lib/getSessionId.ts | 3 + src/types.d.ts | 8 ++ yarn.lock | 12 +++ 16 files changed, 239 insertions(+), 12 deletions(-) delete mode 100644 src/api.ts create mode 100644 src/api/api.ts create mode 100644 src/api/error.ts create mode 100644 src/api/utils.ts create mode 100644 src/components/AuthenticationContext.ts create mode 100644 src/components/AuthenticationWrapper.tsx create mode 100644 src/lib/getSessionId.ts diff --git a/package.json b/package.json index 960b5bc..dc2ce30 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@types/node": "^14.14.37", "@types/react": "^17.0.3", "@types/react-router-dom": "^5.1.7", + "axios": "^0.21.1", "bootstrap": "^4.6.0", "jquery": "^3.6.0", "popper.js": "^1.16.1", diff --git a/src/App.tsx b/src/App.tsx index 6cb2d84..612b174 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,10 +16,11 @@ import Main from './components/Main'; //import 'bootstrap/dist/js/bootstrap.bundle.min'; import './App.css'; import Authenticator from './components/Authenticator'; +import AuthenticationWrapper from './components/AuthenticationWrapper'; function App() { return ( -
+
+ ); } diff --git a/src/api.ts b/src/api.ts deleted file mode 100644 index 79bd110..0000000 --- a/src/api.ts +++ /dev/null @@ -1,5 +0,0 @@ -// eslint-disable-next-line -const dev = process.env.NODE_ENV === 'development'; -export const API_ENDPOINT = 'http://localhost:5000/api'; -export const ION_AUTHORIZATION_ENDPOINT = - 'https://ion.tjhsst.edu/oauth/authorize?response_type=code&client_id=rNa6n9YSg8ftINdyVPpUsaMuxNbHLo9dh1OsOktR&scope=read&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fion%2Fcallback'; diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100644 index 0000000..794d9bf --- /dev/null +++ b/src/api/api.ts @@ -0,0 +1,42 @@ +import axios from 'axios'; +import { APIError } from './error'; +import { makeAPIGetCall } from './utils'; + +// eslint-disable-next-line +const dev = process.env.NODE_ENV === 'development'; +export const API_ENDPOINT = 'http://localhost:5000/api'; +export const ION_AUTHORIZATION_ENDPOINT = + 'https://ion.tjhsst.edu/oauth/authorize?response_type=code&client_id=rNa6n9YSg8ftINdyVPpUsaMuxNbHLo9dh1OsOktR&scope=read&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fauth%2Fion%2Fcallback'; + +axios.defaults.baseURL = API_ENDPOINT; + +/** + * + * @param code The code sent by the OAuth provider + * @param provider The provider that send the OAuth code + */ +export async function createSession( + code: string, + provider: string +): Promise { + const response = await axios.post('/auth/create_session', { + code, + provider, + }); + + if (response.status === 200) { + return response.data.session_id; + } else { + throw new APIError('/auth/create_session', response.data.error); + } +} + +export async function getMe(): Promise { + let result = await makeAPIGetCall('/users/@me'); + return result.data.data; +} + +export async function getPublicUser(id: string): Promise { + let result = await makeAPIGetCall('/users/' + id); + return result.data.data; +} diff --git a/src/api/error.ts b/src/api/error.ts new file mode 100644 index 0000000..40815f7 --- /dev/null +++ b/src/api/error.ts @@ -0,0 +1,5 @@ +export class APIError extends Error { + constructor(route: string, message: string) { + super('API Error @' + route + ': ' + message); + } +} diff --git a/src/api/utils.ts b/src/api/utils.ts new file mode 100644 index 0000000..7c6f25d --- /dev/null +++ b/src/api/utils.ts @@ -0,0 +1,102 @@ +import axios, { AxiosResponse } from 'axios'; +import getSessionId from '../lib/getSessionId'; +import { APIError } from './error'; + +export function createUrlWithGetParameters( + url: string, + params?: Record +) { + if (params) { + // Stringify the parameters + let stringifiedParameters = ''; + for (let [name, value] of Object.entries(params)) { + if (value) { + stringifiedParameters += + encodeURIComponent(name) + '=' + encodeURIComponent(value); + } + } + if (stringifiedParameters) { + url += '?' + stringifiedParameters; + } + } + return url; +} + +export function graphql(query: string, variables?: any) { + return new Promise((resolve, reject) => { + axios + .post( + '/graphql', + { query, variables }, + { headers: { Authorization: 'Bearer ' + getSessionId() } } + ) + .then((successfulResponse) => { + resolve(successfulResponse.data.data); + }) + .catch((error) => { + if (error.response.status === 401) { + localStorage.removeItem('session_id'); + window.location.pathname = '/'; + } else { + reject(new APIError('/api/users/@me', error.response.data.error)); + } + }); + }); +} + +export function makeAPIPostCall(url: string, data?: any) { + return new Promise((resolve, reject) => { + axios + .post(url, data, { + headers: { Authorization: 'Bearer ' + getSessionId() }, + }) + .then((successfulResponse) => resolve(successfulResponse)) + .catch((error) => { + if (error.response?.status === 401) { + localStorage.removeItem('session_id'); + window.location.pathname = '/'; + } else { + reject(new APIError(url, error.response?.data?.error)); + } + }); + }); +} + +export function makeAPIGetCall( + url: string, + params?: Record +) { + url = createUrlWithGetParameters(url, params); + return new Promise((resolve, reject) => { + axios + .get(url, { + headers: { Authorization: 'Bearer ' + getSessionId() }, + }) + .then((successfulResponse) => resolve(successfulResponse)) + .catch((error) => { + if (error.response?.status === 401) { + localStorage.removeItem('session_id'); + window.location.pathname = '/'; + } else { + reject(error); + } + }); + }); +} + +export function makeAPIDeleteCall(url: string) { + return new Promise((resolve, reject) => { + axios + .delete(url, { + headers: { Authorization: 'Bearer ' + getSessionId() }, + }) + .then((successfulResponse) => resolve(successfulResponse)) + .catch((error) => { + if (error.response?.status === 401) { + localStorage.removeItem('session_id'); + } else { + reject(new APIError(url, error.response.data.error)); + } + }); + }); +} diff --git a/src/components/AuthenticationContext.ts b/src/components/AuthenticationContext.ts new file mode 100644 index 0000000..90c2050 --- /dev/null +++ b/src/components/AuthenticationContext.ts @@ -0,0 +1,19 @@ +import { createContext } from 'react'; + +export type AuthState = { + isLoggedIn: boolean | null; + user: Carpool.User | null; + + /** + * Function that can be used to trigger an auth state refresh. + */ + refreshAuthState: (() => void) | null; +}; + +const AuthContext = createContext({ + isLoggedIn: false, + user: null, + refreshAuthState: null, +}); + +export default AuthContext; diff --git a/src/components/AuthenticationWrapper.tsx b/src/components/AuthenticationWrapper.tsx new file mode 100644 index 0000000..00aefd5 --- /dev/null +++ b/src/components/AuthenticationWrapper.tsx @@ -0,0 +1,39 @@ +import { useCallback, useEffect, useState } from 'react'; +import { getMe } from '../api/api'; +import AuthContext, { AuthState } from './AuthenticationContext'; + +export default function AuthenticationWrapper({ + children, +}: { + children: React.ReactNode; +}) { + const sessionId = localStorage.getItem('session_id'); + // Prevent race conditions + const [authState, setAuthState] = useState({ + isLoggedIn: null, + user: null, + refreshAuthState: null, + }); + + const refreshAuthState = useCallback(() => { + if (sessionId) { + getMe().then((user) => { + setAuthState({ isLoggedIn: true, user, refreshAuthState }); + }); + } else { + setAuthState({ isLoggedIn: false, user: null, refreshAuthState }); + } + }, [sessionId]); + + useEffect(() => { + refreshAuthState(); + }, [refreshAuthState]); + + if (authState?.isLoggedIn == null) { + return null; + } else { + return ( + {children} + ); + } +} diff --git a/src/components/Authenticator.tsx b/src/components/Authenticator.tsx index 5a22f91..2002aa3 100644 --- a/src/components/Authenticator.tsx +++ b/src/components/Authenticator.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { Redirect, useLocation, useParams } from 'react-router-dom'; -import { API_ENDPOINT } from '../api'; +import { API_ENDPOINT } from '../api/api'; export default function Authenticator() { const { provider } = useParams<{ provider: string }>(); diff --git a/src/components/Group.tsx b/src/components/Group.tsx index b9eadcf..92749d0 100644 --- a/src/components/Group.tsx +++ b/src/components/Group.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useParams } from 'react-router-dom'; -import { API_ENDPOINT } from '../api'; +import { API_ENDPOINT } from '../api/api'; const maybePluralize = (count: number, noun: string, suffix = 's') => `${count} ${noun}${count !== 1 ? suffix : ''}`; diff --git a/src/components/Home.tsx b/src/components/Home.tsx index cd1d105..a5555ad 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -1,4 +1,4 @@ -import { ION_AUTHORIZATION_ENDPOINT } from '../api'; +import { ION_AUTHORIZATION_ENDPOINT } from '../api/api'; import Button from '@material-ui/core/Button'; export default function Home() { diff --git a/src/components/MyPools.tsx b/src/components/MyPools.tsx index 2973c70..0dc3fc4 100644 --- a/src/components/MyPools.tsx +++ b/src/components/MyPools.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { API_ENDPOINT } from '../api'; +import { API_ENDPOINT } from '../api/api'; const MyPools = () => { // const id = props.match.params.id; diff --git a/src/components/Pools.tsx b/src/components/Pools.tsx index bc9b8bf..b5c1457 100644 --- a/src/components/Pools.tsx +++ b/src/components/Pools.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { API_ENDPOINT } from '../api'; +import { API_ENDPOINT } from '../api/api'; const maybePluralize = (count: number, noun: string, suffix = 's') => `${count} ${noun}${count !== 1 ? suffix : ''}`; diff --git a/src/lib/getSessionId.ts b/src/lib/getSessionId.ts new file mode 100644 index 0000000..f1421d5 --- /dev/null +++ b/src/lib/getSessionId.ts @@ -0,0 +1,3 @@ +export default function getSessionId() { + return localStorage.getItem('session_id'); +} diff --git a/src/types.d.ts b/src/types.d.ts index e1553f1..f1dfc35 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -4,6 +4,14 @@ declare namespace Carpool { member_ids: string[]; } + // Omits the email attribute + export interface PublicUser { + id: string; + username: string; + first_name: string; + last_name: string; + } + export interface User { id: string; email: string; diff --git a/yarn.lock b/yarn.lock index 71f9dca..0d7f88d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2647,6 +2647,13 @@ axe-core@^4.0.2: resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.1.2.tgz" integrity sha512-V+Nq70NxKhYt89ArVcaNL9FDryB3vQOd+BFXZIfO3RP6rwtj+2yqqqdHEkacutglPaZLkJeuXKCjCJDMGPtPqg== +axios@^0.21.1: + version "0.21.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8" + integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA== + dependencies: + follow-redirects "^1.10.0" + axobject-query@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz" @@ -5163,6 +5170,11 @@ follow-redirects@^1.0.0: resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.2.tgz" integrity sha512-6mPTgLxYm3r6Bkkg0vNM0HTjfGrOEtsfbhagQvbxDEsEkpNhw582upBaoRZylzen6krEmxXJgt9Ju6HiI4O7BA== +follow-redirects@^1.10.0: + version "1.13.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267" + integrity sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA== + for-in@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz"