diff --git a/client/index.html b/client/index.html index e0d1c84..71799b5 100644 --- a/client/index.html +++ b/client/index.html @@ -1,13 +1,17 @@ + - - - - - Vite + React + TS - - -
- - + + + + + + Simponic's Friends + + + +
+ diff --git a/client/package-lock.json b/client/package-lock.json index 07f4c75..5347991 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 072e9b5..73dd572 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/App.css b/client/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/client/src/App.css +++ /dev/null @@ -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; -} diff --git a/client/src/App.tsx b/client/src/App.tsx deleted file mode 100644 index 5fc6433..0000000 --- a/client/src/App.tsx +++ /dev/null @@ -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 ( -
-
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

-
- ); -} - -export default App; diff --git a/client/src/context/authContext.tsx b/client/src/context/authContext.tsx new file mode 100644 index 0000000..d5f0635 --- /dev/null +++ b/client/src/context/authContext.tsx @@ -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({ + 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(false); + const [sessionOver, setSessionOver] = useState(new Date()); + const [friendId, setFriendId] = useState(null); + const [friendName, setFriendName] = useState(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 ( + + {children} + + ); +}; diff --git a/client/src/main.tsx b/client/src/main.tsx index 791f139..50c5a66 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -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: ( + + + + ), + errorElement: , + }, + { + path: "/login", + element: , + }, +]); + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - , -) + + + + +); diff --git a/client/src/routes/login.tsx b/client/src/routes/login.tsx new file mode 100644 index 0000000..015c42b --- /dev/null +++ b/client/src/routes/login.tsx @@ -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 ; + if (!token) + return ( +
+
+
+ + + {errors.length ? ( + errors.map((error, i) => ( +
+ {error} +
+ )) + ) : ( + <> + )} + + +
+
+
+ ); + return ( +
+
+
Please sign the following payload with your PGP key:
+ {token} +
+
+