From 4666d7871a9e064a3b3033c7c1daa9c3c4972d98 Mon Sep 17 00:00:00 2001 From: Logan Hunt Date: Thu, 19 Jan 2023 14:04:10 -0700 Subject: Web Client (#11) * Github Oauth * A simple frontend * Add middleware proxy on dev * Forward proxy and rewrite path, add oauth to frontend, increase jwt expiry time to 12 hours * Some simple style changes * Add keys as user * Checkpoint - auth is broken * Fix auth and use player model, unrelated to this pr: flip board if dark * Close player session when password or key deleted or put * Add build script - this branch is quickly becoming cringe * Docker v2 - add migration and scripts, fix local storage and index that caused build issues * Ignore keys, proxy api correctly nginx * Finally nginx is resolved jesus christ * Remove max screen dimension limits cuz cringe * Cursor highlight * Add password form, some minor frontend changes as well * Remove cringe on home page * Move to 127.0.0.1 loopback in env * Add github id in player structs for tests --- front/src/assets/DMMono-Light.ttf | Bin 0 -> 48456 bytes front/src/assets/chessh_lg.svg | 1 + front/src/assets/chessh_sm.svg | 1 + front/src/context/auth_context.js | 71 +++++++++++ front/src/hooks/useLocalStorage.js | 31 +++++ front/src/index.css | 225 +++++++++++++++++++++++++++++++++++ front/src/index.js | 47 ++++++++ front/src/root.jsx | 47 ++++++++ front/src/routes/auth_successful.jsx | 47 ++++++++ front/src/routes/demo.jsx | 61 ++++++++++ front/src/routes/home.jsx | 62 ++++++++++ front/src/routes/keys.jsx | 204 +++++++++++++++++++++++++++++++ front/src/routes/password.jsx | 144 ++++++++++++++++++++++ front/src/setupProxy.js | 21 ++++ 14 files changed, 962 insertions(+) create mode 100644 front/src/assets/DMMono-Light.ttf create mode 100644 front/src/assets/chessh_lg.svg create mode 100644 front/src/assets/chessh_sm.svg create mode 100644 front/src/context/auth_context.js create mode 100644 front/src/hooks/useLocalStorage.js create mode 100644 front/src/index.css create mode 100644 front/src/index.js create mode 100644 front/src/root.jsx create mode 100644 front/src/routes/auth_successful.jsx create mode 100644 front/src/routes/demo.jsx create mode 100644 front/src/routes/home.jsx create mode 100644 front/src/routes/keys.jsx create mode 100644 front/src/routes/password.jsx create mode 100644 front/src/setupProxy.js (limited to 'front/src') diff --git a/front/src/assets/DMMono-Light.ttf b/front/src/assets/DMMono-Light.ttf new file mode 100644 index 0000000..2b14cea Binary files /dev/null and b/front/src/assets/DMMono-Light.ttf differ diff --git a/front/src/assets/chessh_lg.svg b/front/src/assets/chessh_lg.svg new file mode 100644 index 0000000..0838ef2 --- /dev/null +++ b/front/src/assets/chessh_lg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/front/src/assets/chessh_sm.svg b/front/src/assets/chessh_sm.svg new file mode 100644 index 0000000..d4e22d4 --- /dev/null +++ b/front/src/assets/chessh_sm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/front/src/context/auth_context.js b/front/src/context/auth_context.js new file mode 100644 index 0000000..19747c6 --- /dev/null +++ b/front/src/context/auth_context.js @@ -0,0 +1,71 @@ +import { useEffect, useContext, createContext } from "react"; +import { useLocalStorage } from "../hooks/useLocalStorage"; + +const AuthContext = createContext({ + signedIn: false, + setSignedIn: () => null, + sessionOver: new Date(), + setSessionOver: () => null, + setPlayer: () => null, + player: null, + signOut: () => null, +}); + +export const useAuthContext = () => useContext(AuthContext); + +export const AuthProvider = ({ children }) => { + const [signedIn, setSignedIn] = useLocalStorage("signedIn", false); + const [sessionOver, setSessionOver] = useLocalStorage( + "sessionOver", + Date.now() + ); + const [player, setPlayer] = useLocalStorage("player", null); + + const setDefaults = () => { + setPlayer(null); + setSessionOver(Date.now()); + setSignedIn(false); + }; + + const signOut = () => + fetch("/api/player/logout", { + method: "GET", + credentials: "same-origin", + }).then(() => setDefaults()); + + useEffect(() => { + setTimeout(() => { + setSessionOver((sessionOver) => { + if (Date.now() >= sessionOver) { + setSignedIn((signedIn) => { + if (signedIn) + alert( + "Session expired. Any further privileged requests will fail until signed in again." + ); + + return false; + }); + setPlayer(null); + } + return sessionOver; + }); + }, sessionOver - Date.now()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionOver]); + + return ( + + {children} + + ); +}; diff --git a/front/src/hooks/useLocalStorage.js b/front/src/hooks/useLocalStorage.js new file mode 100644 index 0000000..f00a11b --- /dev/null +++ b/front/src/hooks/useLocalStorage.js @@ -0,0 +1,31 @@ +import { useState, useEffect } from "react"; + +const STORAGE_KEYS_PREFIX = "chessh-"; + +const useStorage = (storage, keyPrefix) => (storageKey, fallbackState) => { + if (!storageKey) + throw new Error( + `"storageKey" must be a nonempty string, but "${storageKey}" was passed.` + ); + + const storedString = storage.getItem(keyPrefix + storageKey); + let parsedObject = null; + + if (storedString !== null) parsedObject = JSON.parse(storedString); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const [value, setValue] = useState(parsedObject ?? fallbackState); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + storage.setItem(keyPrefix + storageKey, JSON.stringify(value)); + }, [value, storageKey]); + + return [value, setValue]; +}; + +// eslint-disable-next-line react-hooks/rules-of-hooks +export const useLocalStorage = useStorage( + window.localStorage, + STORAGE_KEYS_PREFIX +); diff --git a/front/src/index.css b/front/src/index.css new file mode 100644 index 0000000..40d45cb --- /dev/null +++ b/front/src/index.css @@ -0,0 +1,225 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + --main-bg-color: #282828; + --primary-text-color: #ebdbb2; + --success-color: #689d6a; + --success-color-hover: #8ec07c; + --gold-color: #d79921; + --gold-color-hover: #fabd2f; + --blue-color: #458488; + --blue-color-hover: #83a598; + --purple-color: #b16286; + --purple-color-hover: #d3869b; + --red-color: #cc241d; + --red-color-hover: #fb4934; +} + +@font-face { + font-family: "DM Mono"; + src: url("./assets/DMMono-Light.ttf"); +} + +body { + margin: 0; + padding: 0; + background-color: var(--main-bg-color); + font-family: "DM Mono"; + color: var(--primary-text-color); +} + +.button { + cursor: pointer; + flex-shrink: 0; + color: var(--main-bg-color); + text-decoration: none; + border-radius: 8px; + border: var(--primary-text-color) solid 1px; + background-color: var(--success-color); + padding: 0.5rem; + + font-family: "DM Mono"; +} +.button:hover { + background-color: var(--success-color-hover); +} +.gold { + background-color: var(--gold-color); +} +.gold:hover { + background-color: var(--gold-color-hover); +} +.red { + color: var(--primary-text-color); + background-color: var(--red-color); +} +.red:hover { + background-color: var(--red-color-hover); +} + +.logo { + width: 6rem; + height: 6rem; +} + +.navbar { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + margin-bottom: 1rem; + border-radius: 12px; + padding: 0.5rem; + padding-left: 2rem; + padding-right: 2rem; + border: var(--purple-color) solid 1px; +} + +a { + text-decoration: underline; + color: var(--success-color); +} + +a:hover { + background-color: var(--success-color-hover); + text-decoration: none; +} + +.link { + font-size: 1.25rem; +} + +.nav { + display: flex; + flex-direction: row; + justify-content: space-around; + align-items: center; + gap: 2rem; +} + +.flex-row-around { + display: flex; + flex-direction: row; + justify-content: space-around; + align-items: center; + gap: 2rem; +} + +.flex-end-row { + display: flex; + flex-direction: row; + justify-content: flex-end; + gap: 1rem; +} + +.container { + padding-top: 1rem; + max-width: 1200px; + width: 80%; + + margin-left: auto; + margin-right: auto; +} + +.demo-container { + max-width: 900px; + width: 80%; + + border: 1px solid #b16286; + border-radius: 8px; + margin: 0; + padding: 24px; + + background-color: var(--main-bg-color); + + box-shadow: rgb(0, 0, 0, 0.6) 6px 45px 45px -12px; + + position: absolute; + top: 50%; + left: 50%; + -moz-transform: translateX(-50%) translateY(-50%); + -webkit-transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%) translateY(-50%); +} + +.key-card { + display: flex; + justify-content: space-around; + flex-direction: row; + align-items: center; + padding-left: 1rem; + padding-right: 1rem; + border-radius: 12px; + border: solid 1px var(--gold-color); + margin-top: 12px; + gap: 0.5rem; +} + +.key-card-collection { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +input, +textarea { + font-family: "DM Mono"; + color: var(--primary-text-color); + background-color: rgba(0, 0, 0, 0.2); + border-radius: 4px; + border: 1px solid var(--primary-text-color); +} +input:focus, +textarea:focus { + border: 1px solid var(--gold-color); +} + +.modal { + display: flex; + flex-direction: column; + gap: 1rem; + + padding: 3rem; + top: 50%; + left: 50%; + -moz-transform: translateX(-50%) translateY(-50%); + -webkit-transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%) translateY(-50%); + position: absolute; + + border-radius: 12px; + border: solid 1px var(--purple-color); + background-color: var(--main-bg-color); +} + +@media screen and (max-width: 680px) { + .container { + width: 95%; + } + .navbar { + flex-direction: column; + } + .key-card { + flex-direction: column; + justify-content: start; + gap: 0; + align-items: start; + padding-bottom: 1rem; + } + .flex-row-around { + flex-direction: column; + gap: 0; + } +} + +@media screen and (max-width: 1200px) { + .key-card-collection { + display: flex; + flex-direction: column; + } +} diff --git a/front/src/index.js b/front/src/index.js new file mode 100644 index 0000000..e827e0a --- /dev/null +++ b/front/src/index.js @@ -0,0 +1,47 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { createBrowserRouter, RouterProvider } from "react-router-dom"; + +import { AuthProvider } from "./context/auth_context"; +import { Root } from "./root"; +import { Demo } from "./routes/demo"; +import { Home } from "./routes/home"; +import { Keys } from "./routes/keys"; +import { Password } from "./routes/password"; +import { AuthSuccessful } from "./routes/auth_successful"; + +import "./index.css"; + +const router = createBrowserRouter([ + { path: "/", element: }, + { + path: "/", + element: , + errorElement: <> , + children: [ + { + path: "home", + element: , + }, + { + path: "password", + element: , + }, + { + path: "keys", + element: , + }, + { + path: "auth-successful", + element: , + }, + ], + }, +]); + +const root = ReactDOM.createRoot(document.getElementById("root")); +root.render( + + + +); diff --git a/front/src/root.jsx b/front/src/root.jsx new file mode 100644 index 0000000..61f8615 --- /dev/null +++ b/front/src/root.jsx @@ -0,0 +1,47 @@ +import { Link, Outlet } from "react-router-dom"; + +import logo from "./assets/chessh_sm.svg"; + +import { useAuthContext } from "./context/auth_context"; + +export const Root = () => { + const { signedIn, signOut } = useAuthContext(); + + return ( + <> +
+
+
+ + CheSSH Logo + +
+
+ {signedIn ? ( + <> + + Password + + + Keys + + + Sign Out + + + ) : ( + <> + + 🐙 Login w/ GitHub 🐙 + + + )} +
+
+
+ +
+
+ + ); +}; diff --git a/front/src/routes/auth_successful.jsx b/front/src/routes/auth_successful.jsx new file mode 100644 index 0000000..7c66587 --- /dev/null +++ b/front/src/routes/auth_successful.jsx @@ -0,0 +1,47 @@ +import { useEffect } from "react"; +import { Link } from "react-router-dom"; + +import { useAuthContext } from "../context/auth_context"; + +export const AuthSuccessful = () => { + const { player, setPlayer, signedIn, setSignedIn, setSessionOver } = + useAuthContext(); + + useEffect(() => { + fetch("/api/player/token/me", { + credentials: "same-origin", + }) + .then((r) => r.json()) + .then(({ player, expiration }) => { + setSignedIn(!!player); + setPlayer(player); + setSessionOver(expiration); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (signedIn) { + return ( + <> +

Hello there, {player?.username || ""}!

+
+ If you have not already done so: + + Add a Public Key + +
+
+
+ + Go Home + +
+ + ); + } + return ( + <> +

Loading...

+ + ); +}; diff --git a/front/src/routes/demo.jsx b/front/src/routes/demo.jsx new file mode 100644 index 0000000..b1a2f88 --- /dev/null +++ b/front/src/routes/demo.jsx @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; + +import * as AsciinemaPlayer from "asciinema-player"; +import "asciinema-player/dist/bundle/asciinema-player.css"; + +const demoProps = { + theme: "tango", + startAt: 12, + autoPlay: true, +}; + +const demoCast = "/chessh.cast"; +const demoCastElementId = "demo"; + +export const Demo = () => { + const player = useRef(null); + const [renderedPlayer, setRenderedPlayer] = useState(false); + + useEffect(() => { + if (!renderedPlayer) { + AsciinemaPlayer.create( + demoCast, + document.getElementById(demoCastElementId), + demoProps + ); + setRenderedPlayer(true); + } + }, [renderedPlayer, player]); + + return ( +
+

+ Welcome to > CheSSH! +

+
+

+ CheSSH is a multiplayer, scalable, free, open source, and (optionally) + passwordless game of Chess over the SSH protocol, written in Elixir. +

+ + 🌟 Star Repo 🌟 + +
+
+
+
+
+

Would you like to play a game?

+ + Yes, Falken ⇒ + +
+
+ ); +}; diff --git a/front/src/routes/home.jsx b/front/src/routes/home.jsx new file mode 100644 index 0000000..baccb5f --- /dev/null +++ b/front/src/routes/home.jsx @@ -0,0 +1,62 @@ +import { CopyBlock, dracula } from "react-code-blocks"; +import { Link } from "react-router-dom"; + +import { useAuthContext } from "../context/auth_context"; + +export const Home = () => { + const { player, signedIn } = useAuthContext(); + + if (signedIn) { + const sshConfig = `Host chessh + Hostname ${process.env.REACT_APP_SSH_SERVER} + Port ${process.env.REACT_APP_SSH_PORT} + User ${player?.username} + PubkeyAuthentication yes`; + return ( + <> +

Welcome, {player?.username}

+
+

Getting Started

+
    +
    +
  1. + Add a public key, or{" "} + set a password. +
  2. +
    +
    +
  3. + Insert the following block in your{" "} + ssh config: +
  4. + + +
    + +
    +
  5. Then, connect with:
  6. + +
    +
+ + ); + } + + return ( + <> +

Looks like you're not signed in 👀.

+ + ); +}; diff --git a/front/src/routes/keys.jsx b/front/src/routes/keys.jsx new file mode 100644 index 0000000..4dee1ce --- /dev/null +++ b/front/src/routes/keys.jsx @@ -0,0 +1,204 @@ +import Modal from "react-modal"; +import { useEffect, useState, useCallback } from "react"; +import { useAuthContext } from "../context/auth_context"; + +Modal.setAppElement("#root"); + +const MINIMIZE_KEY_LEN = 40; +const minimizeKey = (key) => { + const n = key.length; + if (n >= MINIMIZE_KEY_LEN) { + const half = Math.floor(MINIMIZE_KEY_LEN / 2); + return key.substring(0, half) + "..." + key.substring(n - half, n); + } + return key; +}; + +const KeyCard = ({ onDelete, props }) => { + const { id, name, key } = props; + + const deleteThisKey = () => { + if ( + window.confirm( + "Are you sure? This will close all your current ssh sessions." + ) + ) { + fetch(`/api/keys/${id}`, { + credentials: "same-origin", + method: "DELETE", + }) + .then((r) => r.json()) + .then((d) => d.success && onDelete && onDelete()); + } + }; + + return ( +
+

{name}

+

{minimizeKey(key)}

+ + +
+ ); +}; + +const AddKeyButton = ({ onSave }) => { + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [key, setKey] = useState(""); + const [errors, setErrors] = useState(null); + + const setDefaults = () => { + setName(""); + setKey(""); + setErrors(null); + }; + + const close = () => { + setDefaults(); + setOpen(false); + }; + + const createKey = () => { + fetch(`/api/player/keys`, { + credentials: "same-origin", + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + key: key.trim(), + name: name.trim(), + }), + }) + .then((r) => r.json()) + .then((d) => { + if (d.success) { + if (onSave) { + onSave(); + } + close(); + } else if (d.errors) { + if (typeof d.errors === "object") { + setErrors( + Object.keys(d.errors).map( + (field) => `${field}: ${d.errors[field].join(",")}` + ) + ); + } else { + setErrors([d.errors]); + } + } + }); + }; + + return ( +
+ + +
+

Add SSH Key

+

+ Not sure about this? Check{" "} + + here + {" "} + for help! +

+
+

Key Name *

+ setName(e.target.value)} + required + /> +
+
+

SSH Key *

+