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 + 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 (
+
+ );
+ return (
+
+
+
Please sign the following payload with your PGP key:
+
{token}
+
+
+
+
+ );
+}
diff --git a/client/src/routes/notFound.tsx b/client/src/routes/notFound.tsx
new file mode 100644
index 0000000..8b557d1
--- /dev/null
+++ b/client/src/routes/notFound.tsx
@@ -0,0 +1,3 @@
+export default function NotFound() {
+ return Not found
;
+}
diff --git a/client/src/routes/protected.tsx b/client/src/routes/protected.tsx
new file mode 100644
index 0000000..a0f4dbd
--- /dev/null
+++ b/client/src/routes/protected.tsx
@@ -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 ;
+
+ return children;
+}
diff --git a/client/src/routes/root.tsx b/client/src/routes/root.tsx
new file mode 100644
index 0000000..fce48d1
--- /dev/null
+++ b/client/src/routes/root.tsx
@@ -0,0 +1,10 @@
+import { Outlet } from "react-router-dom";
+
+export default function Root() {
+ return (
+ <>
+ Hello
+
+ >
+ );
+}
diff --git a/client/src/styles/login.css b/client/src/styles/login.css
new file mode 100644
index 0000000..9d93289
--- /dev/null
+++ b/client/src/styles/login.css
@@ -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;
+}
diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts
index 172736a..d04939d 100644
--- a/server/src/auth/auth.controller.ts
+++ b/server/src/auth/auth.controller.ts
@@ -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(