Add better API, authentication wrapper

This commit is contained in:
Michael Fatemi 2021-04-10 16:14:29 -04:00
parent d12b289176
commit 5f8711833f
16 changed files with 239 additions and 12 deletions

View File

@ -12,6 +12,7 @@
"@types/node": "^14.14.37", "@types/node": "^14.14.37",
"@types/react": "^17.0.3", "@types/react": "^17.0.3",
"@types/react-router-dom": "^5.1.7", "@types/react-router-dom": "^5.1.7",
"axios": "^0.21.1",
"bootstrap": "^4.6.0", "bootstrap": "^4.6.0",
"jquery": "^3.6.0", "jquery": "^3.6.0",
"popper.js": "^1.16.1", "popper.js": "^1.16.1",

View File

@ -16,10 +16,11 @@ import Main from './components/Main';
//import 'bootstrap/dist/js/bootstrap.bundle.min'; //import 'bootstrap/dist/js/bootstrap.bundle.min';
import './App.css'; import './App.css';
import Authenticator from './components/Authenticator'; import Authenticator from './components/Authenticator';
import AuthenticationWrapper from './components/AuthenticationWrapper';
function App() { function App() {
return ( return (
<div className="App"> <AuthenticationWrapper>
<BrowserRouter> <BrowserRouter>
<Nav /> <Nav />
<Switch> <Switch>
@ -37,7 +38,7 @@ function App() {
<Route component={Home} path="/" /> <Route component={Home} path="/" />
</Switch> </Switch>
</BrowserRouter> </BrowserRouter>
</div> </AuthenticationWrapper>
); );
} }

View File

@ -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';

42
src/api/api.ts Normal file
View File

@ -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<string> {
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<Carpool.User> {
let result = await makeAPIGetCall('/users/@me');
return result.data.data;
}
export async function getPublicUser(id: string): Promise<Carpool.PublicUser> {
let result = await makeAPIGetCall('/users/' + id);
return result.data.data;
}

5
src/api/error.ts Normal file
View File

@ -0,0 +1,5 @@
export class APIError extends Error {
constructor(route: string, message: string) {
super('API Error @' + route + ': ' + message);
}
}

102
src/api/utils.ts Normal file
View File

@ -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<string, string | undefined>
) {
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<AxiosResponse>((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<AxiosResponse>((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<string, string | undefined>
) {
url = createUrlWithGetParameters(url, params);
return new Promise<AxiosResponse>((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<AxiosResponse>((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));
}
});
});
}

View File

@ -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<AuthState>({
isLoggedIn: false,
user: null,
refreshAuthState: null,
});
export default AuthContext;

View File

@ -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<AuthState>({
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 (
<AuthContext.Provider value={authState}>{children}</AuthContext.Provider>
);
}
}

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Redirect, useLocation, useParams } from 'react-router-dom'; import { Redirect, useLocation, useParams } from 'react-router-dom';
import { API_ENDPOINT } from '../api'; import { API_ENDPOINT } from '../api/api';
export default function Authenticator() { export default function Authenticator() {
const { provider } = useParams<{ provider: string }>(); const { provider } = useParams<{ provider: string }>();

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom'; 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') => const maybePluralize = (count: number, noun: string, suffix = 's') =>
`${count} ${noun}${count !== 1 ? suffix : ''}`; `${count} ${noun}${count !== 1 ? suffix : ''}`;

View File

@ -1,4 +1,4 @@
import { ION_AUTHORIZATION_ENDPOINT } from '../api'; import { ION_AUTHORIZATION_ENDPOINT } from '../api/api';
import Button from '@material-ui/core/Button'; import Button from '@material-ui/core/Button';
export default function Home() { export default function Home() {

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { API_ENDPOINT } from '../api'; import { API_ENDPOINT } from '../api/api';
const MyPools = () => { const MyPools = () => {
// const id = props.match.params.id; // const id = props.match.params.id;

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { API_ENDPOINT } from '../api'; import { API_ENDPOINT } from '../api/api';
const maybePluralize = (count: number, noun: string, suffix = 's') => const maybePluralize = (count: number, noun: string, suffix = 's') =>
`${count} ${noun}${count !== 1 ? suffix : ''}`; `${count} ${noun}${count !== 1 ? suffix : ''}`;

3
src/lib/getSessionId.ts Normal file
View File

@ -0,0 +1,3 @@
export default function getSessionId() {
return localStorage.getItem('session_id');
}

8
src/types.d.ts vendored
View File

@ -4,6 +4,14 @@ declare namespace Carpool {
member_ids: string[]; member_ids: string[];
} }
// Omits the email attribute
export interface PublicUser {
id: string;
username: string;
first_name: string;
last_name: string;
}
export interface User { export interface User {
id: string; id: string;
email: string; email: string;

View File

@ -2647,6 +2647,13 @@ axe-core@^4.0.2:
resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.1.2.tgz" resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.1.2.tgz"
integrity sha512-V+Nq70NxKhYt89ArVcaNL9FDryB3vQOd+BFXZIfO3RP6rwtj+2yqqqdHEkacutglPaZLkJeuXKCjCJDMGPtPqg== 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: axobject-query@^2.2.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz" 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" resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.2.tgz"
integrity sha512-6mPTgLxYm3r6Bkkg0vNM0HTjfGrOEtsfbhagQvbxDEsEkpNhw582upBaoRZylzen6krEmxXJgt9Ju6HiI4O7BA== 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: for-in@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz" resolved "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz"