Auth context for frontend

This commit is contained in:
Elizabeth Hunt 2023-04-03 08:52:10 -06:00
parent 9fa2872446
commit 4dfc3129e3
Signed by: simponic
GPG Key ID: 52B3774857EB24B1
13 changed files with 360 additions and 102 deletions

View File

@ -1,13 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content=
<title>Vite + React + TS</title> "width=device-width, initial-scale=1.0">
</head>
<body> <title>Simponic's Friends</title>
<div id="root"></div> </head>
<script type="module" src="/src/main.tsx"></script>
</body> <body>
<div id="root"></div><script type="module" src="/src/main.tsx">
</script>
</body>
</html> </html>

View File

@ -8,8 +8,10 @@
"name": "client", "name": "client",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"chota": "^0.9.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0",
"react-router-dom": "^6.10.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.28", "@types/react": "^18.0.28",
@ -774,6 +776,14 @@
"@jridgewell/sourcemap-codec": "1.4.14" "@jridgewell/sourcemap-codec": "1.4.14"
} }
}, },
"node_modules/@remix-run/router": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.5.0.tgz",
"integrity": "sha512-bkUDCp8o1MvFO+qxkODcbhSqRa6P2GXgrGZVpt0dCXNW2HCSCqYI0ZoAqEOSAjRWmmlKcYgFvN4B4S+zo/f8kg==",
"engines": {
"node": ">=14"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.5", "version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
@ -899,6 +909,11 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/chota": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/chota/-/chota-0.9.2.tgz",
"integrity": "sha512-DmCHT/R+yMr/aYQuokkJeNIjknSgpMpM9mR0tDlqPu8A+lGo9TS/KFPpze5Q9PPrCWenp/VG7Y10usyNDoIygQ=="
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "1.9.3", "version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@ -1234,6 +1249,36 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-router": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.10.0.tgz",
"integrity": "sha512-Nrg0BWpQqrC3ZFFkyewrflCud9dio9ME3ojHCF/WLsprJVzkq3q3UeEhMCAW1dobjeGbWgjNn/PVF6m46ANxXQ==",
"dependencies": {
"@remix-run/router": "1.5.0"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.10.0.tgz",
"integrity": "sha512-E5dfxRPuXKJqzwSe/qGcqdwa18QiWC6f3H3cWXM24qj4N0/beCIf/CWTipop2xm7mR0RCS99NnaqPNjHtrAzCg==",
"dependencies": {
"@remix-run/router": "1.5.0",
"react-router": "6.10.0"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.1", "version": "1.22.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",

View File

@ -9,8 +9,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"chota": "^0.9.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0" "react-dom": "^18.2.0",
"react-router-dom": "^6.10.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.0.28", "@types/react": "^18.0.28",

View File

@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -1,35 +0,0 @@
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "/vite.svg";
import "./App.css";
function App() {
const [count, setCount] = useState(0);
return (
<div className="App">
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://reactjs.org" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</div>
);
}
export default App;

View File

@ -0,0 +1,104 @@
import React, { useContext, useState, createContext, useEffect } from "react";
interface authContext {
signedIn: boolean;
setSignedIn: (signedIn: boolean) => void;
sessionOver: Date;
setSessionOver: (expiry: Date) => void;
friendId: number | null;
setFriendId: (newFriendId: number | null) => void;
friendName: string | null;
setFriendName: (newFriendName: string | null) => void;
}
const AuthContext = createContext<authContext>({
signedIn: false,
setSignedIn: () => null,
sessionOver: new Date(),
setSessionOver: () => null,
friendId: null,
setFriendId: () => null,
friendName: "",
setFriendName: () => null,
});
export const useAuthContext = () => useContext(AuthContext);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
const [signedIn, setSignedIn] = useState<boolean>(false);
const [sessionOver, setSessionOver] = useState<Date>(new Date());
const [friendId, setFriendId] = useState<number | null>(null);
const [friendName, setFriendName] = useState<string | null>(null);
useEffect(() => {
if (friendName) {
localStorage.setItem("friendName", friendName);
}
}, [friendName]);
useEffect(() => {
if (friendId) {
localStorage.setItem("friendId", friendId);
}
}, [friendId]);
useEffect(() => {
let expiry: string | null | Date = localStorage.getItem("expiry");
if (expiry) {
expiry = new Date(expiry);
if (Date.now() < expiry.getTime()) {
setSignedIn(true);
setSessionOver(expiry);
((friendName) => {
if (friendName) {
setFriendName(friendName);
}
})(localStorage.getItem("friendName"));
((id) => {
if (id) {
setFriendId(parseInt(id, 10));
}
})(localStorage.getItem("friendId"));
}
}
}, []);
useEffect(() => {
localStorage.setItem("expiry", sessionOver.toISOString());
setTimeout(() => {
setSessionOver((sessionOver) => {
if (Date.now() >= sessionOver.getTime()) {
setSignedIn((signedIn) => {
if (signedIn) {
alert(
"Session expired. Any further privileged requests will fail until signed in again."
);
["friendId", "friendName"].map((x) => localStorage.removeItem(x));
return false;
}
return signedIn;
});
}
return sessionOver;
});
}, sessionOver.getTime() - Date.now());
}, [sessionOver]);
return (
<AuthContext.Provider
value={{
signedIn,
setSignedIn,
sessionOver,
setSessionOver,
friendId,
setFriendId,
friendName,
setFriendName,
}}
>
{children}
</AuthContext.Provider>
);
};

View File

@ -1,10 +1,35 @@
import React from 'react' import React from "react";
import ReactDOM from 'react-dom/client' import ReactDOM from "react-dom/client";
import App from './App' import { createBrowserRouter, RouterProvider } from "react-router-dom";
import './index.css' import "chota";
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( import { AuthProvider } from "./context/authContext";
import Root from "./routes/root";
import NotFound from "./routes/notFound";
import Login from "./routes/login";
import ProtectedRoute from "./routes/protected.tsx";
const router = createBrowserRouter([
{
path: "/",
element: (
<ProtectedRoute>
<Root />
</ProtectedRoute>
),
errorElement: <NotFound />,
},
{
path: "/login",
element: <Login />,
},
]);
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <AuthProvider>
</React.StrictMode>, <RouterProvider router={router} />
) </AuthProvider>
</React.StrictMode>
);

112
client/src/routes/login.tsx Normal file
View File

@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { Navigate } from "react-router-dom";
import { useAuthContext } from "../context/authContext";
import "../styles/login.css";
const requestTokenSubmit = async (name) =>
fetch(
"/api/auth?" +
new URLSearchParams({
name,
})
).then((r) => r.json());
const submitSignedToken = async (signature) =>
fetch("/api/auth", {
method: "POST",
body: JSON.stringify({
signature,
}),
headers: {
"Content-Type": "application/json",
},
}).then((r) => r.json());
export default function Login() {
const [token, setToken] = useState("");
const [errors, setErrors] = useState([]);
const { signedIn, setSignedIn, setSessionOver, setFriendId, setFriendName } =
useAuthContext();
const getTokenFormSubmission = async (e) => {
e.preventDefault();
const { error, token } = await requestTokenSubmit(e.target.name.value);
if (error) {
setErrors([error]);
return;
}
setErrors([]);
setToken(token);
};
const signTokenFormSubmission = async (e) => {
e.preventDefault();
const { error, token, expiration, friend } = await submitSignedToken(
e.target.signature.value
);
if (token) {
setSignedIn(true);
setSessionOver(new Date(expiration));
setFriendId(friend.id.toString());
setFriendName(friend.name);
return;
}
setErrors([error]);
};
if (signedIn) return <Navigate to="/" />;
if (!token)
return (
<div className="body-centered">
<form onSubmit={getTokenFormSubmission} autoComplete="off">
<div className="card">
<label htmlFor="name">Name</label>
<input id="name" name="name" />
{errors.length ? (
errors.map((error, i) => (
<div key={i} className="text-error">
{error}
</div>
))
) : (
<></>
)}
<button type="submit">Request Token</button>
</div>
</form>
</div>
);
return (
<div className="body-centered">
<div className="login card">
<div>Please sign the following payload with your PGP key:</div>
<code>{token}</code>
<hr />
<form onSubmit={signTokenFormSubmission}>
<textarea
id="signature"
name="signature"
rows="6"
placeholder="-----BEGIN PGP SIGNED MESSAGE-----"
/>
{errors.length ? (
errors.map((error, i) => (
<div key={i} className="text-error">
{error}
</div>
))
) : (
<></>
)}
<button type="submit">Log In</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export default function NotFound() {
return <h1>Not found</h1>;
}

View File

@ -0,0 +1,10 @@
import { Navigate } from "react-router-dom";
import { useAuthContext } from "../context/authContext";
export default function ProtectedRoute({ children }) {
const { signedIn } = useAuthContext();
if (!signedIn) return <Navigate to="/login" />;
return children;
}

View File

@ -0,0 +1,10 @@
import { Outlet } from "react-router-dom";
export default function Root() {
return (
<>
<h1>Hello</h1>
<Outlet />
</>
);
}

View File

@ -0,0 +1,15 @@
.body-centered {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login {
max-width: 800px;
}
button {
margin-top: 0.5rem;
}

View File

@ -3,7 +3,7 @@ import {
Get, Get,
Post, Post,
Body, Body,
Param, Query,
NotFoundException, NotFoundException,
BadRequestException, BadRequestException,
Res, Res,
@ -20,6 +20,11 @@ export class LoginUserDTO {
signature: string; signature: string;
} }
export class RetrieveTokenDTO {
@IsNotEmpty()
name: string;
}
@Controller('/auth') @Controller('/auth')
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@ -34,9 +39,9 @@ export class AuthController {
return await this.authService.deleteToken(req.token); return await this.authService.deleteToken(req.token);
} }
@Get('/:name') @Get('/')
async retrieveGodToken(@Param('name') name: string) { async retrieveGodToken(@Query() query: RetrieveTokenDTO) {
const friend = await this.authService.findFriendByName(name); const friend = await this.authService.findFriendByName(query.name);
if (!friend) throw new NotFoundException('Friend not found with that name'); if (!friend) throw new NotFoundException('Friend not found with that name');
return await this.authService.createTokenForFriend(friend); return await this.authService.createTokenForFriend(friend);
@ -78,7 +83,7 @@ export class AuthController {
expires: referencedToken.expiration, expires: referencedToken.expiration,
}); });
return await this.authService.signToken(token); return { ...(await this.authService.signToken(token)), friend };
} }
throw new BadRequestException( throw new BadRequestException(