Auth context for frontend
This commit is contained in:
parent
9fa2872446
commit
4dfc3129e3
@ -1,13 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg">
|
||||
<meta name="viewport" content=
|
||||
"width=device-width, initial-scale=1.0">
|
||||
|
||||
<title>Simponic's Friends</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<div id="root"></div><script type="module" src="/src/main.tsx">
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
47
client/package-lock.json
generated
47
client/package-lock.json
generated
@ -8,8 +8,10 @@
|
||||
"name": "client",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"chota": "^0.9.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.28",
|
||||
@ -774,6 +776,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": {
|
||||
"version": "15.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
|
||||
@ -899,6 +909,11 @@
|
||||
"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": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
@ -1234,6 +1249,36 @@
|
||||
"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": {
|
||||
"version": "1.22.1",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz",
|
||||
|
@ -9,8 +9,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"chota": "^0.9.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.28",
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
104
client/src/context/authContext.tsx
Normal file
104
client/src/context/authContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -1,10 +1,35 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
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>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
112
client/src/routes/login.tsx
Normal file
112
client/src/routes/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
3
client/src/routes/notFound.tsx
Normal file
3
client/src/routes/notFound.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export default function NotFound() {
|
||||
return <h1>Not found</h1>;
|
||||
}
|
10
client/src/routes/protected.tsx
Normal file
10
client/src/routes/protected.tsx
Normal 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;
|
||||
}
|
10
client/src/routes/root.tsx
Normal file
10
client/src/routes/root.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export default function Root() {
|
||||
return (
|
||||
<>
|
||||
<h1>Hello</h1>
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
15
client/src/styles/login.css
Normal file
15
client/src/styles/login.css
Normal 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;
|
||||
}
|
@ -3,7 +3,7 @@ import {
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
Res,
|
||||
@ -20,6 +20,11 @@ export class LoginUserDTO {
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export class RetrieveTokenDTO {
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
}
|
||||
|
||||
@Controller('/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
@ -34,9 +39,9 @@ export class AuthController {
|
||||
return await this.authService.deleteToken(req.token);
|
||||
}
|
||||
|
||||
@Get('/:name')
|
||||
async retrieveGodToken(@Param('name') name: string) {
|
||||
const friend = await this.authService.findFriendByName(name);
|
||||
@Get('/')
|
||||
async retrieveGodToken(@Query() query: RetrieveTokenDTO) {
|
||||
const friend = await this.authService.findFriendByName(query.name);
|
||||
if (!friend) throw new NotFoundException('Friend not found with that name');
|
||||
|
||||
return await this.authService.createTokenForFriend(friend);
|
||||
@ -78,7 +83,7 @@ export class AuthController {
|
||||
expires: referencedToken.expiration,
|
||||
});
|
||||
|
||||
return await this.authService.signToken(token);
|
||||
return { ...(await this.authService.signToken(token)), friend };
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
|
Loading…
Reference in New Issue
Block a user