Web Client #11

Merged
Simponic merged 19 commits from web into main 2023-01-19 16:04:10 -05:00
8 changed files with 152 additions and 90 deletions
Showing only changes of commit b98655caa3 - Show all commits

View File

@ -1,25 +1,57 @@
import React, { useContext, useState, createContext } from "react"; import { useEffect, useContext, createContext } from "react";
import { useLocalStorage } from "../hooks/useLocalStorage";
export const DEFAULT_EXPIRY_TIME_MS = 12 * 60 * 60 * 1000;
const AuthContext = createContext({ const AuthContext = createContext({
signedIn: false, signedIn: false,
setSignedIn: () => null, setSignedIn: () => null,
sessionOver: new Date(), sessionOver: new Date(),
setSessionOver: () => null, setSessionOver: () => null,
userId: null, setPlayer: () => null,
setUserId: () => null, player: null,
username: "", signOut: () => null,
setUsername: () => null,
}); });
export const useAuthContext = () => useContext(AuthContext); export const useAuthContext = () => useContext(AuthContext);
export const AuthProvider = ({ children }) => { export const AuthProvider = ({ children }) => {
const [signedIn, setSignedIn] = useState(false); const [signedIn, setSignedIn] = useLocalStorage("signedIn", false);
const [sessionOver, setSessionOver] = useState(new Date()); const [sessionOver, setSessionOver] = useLocalStorage(
const [userId, setUserId] = useState(null); "sessionOver",
const [username, setUsername] = useState(null); 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 ( return (
<AuthContext.Provider <AuthContext.Provider
@ -28,10 +60,9 @@ export const AuthProvider = ({ children }) => {
setSignedIn, setSignedIn,
sessionOver, sessionOver,
setSessionOver, setSessionOver,
userId, signOut,
setUserId, setPlayer,
username, player,
setUsername,
}} }}
> >
{children} {children}

View File

@ -0,0 +1,28 @@
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);
const [value, setValue] = useState(parsedObject ?? fallbackState);
useEffect(() => {
storage.setItem(keyPrefix + storageKey, JSON.stringify(value));
}, [value, storageKey]);
return [value, setValue];
};
export const useLocalStorage = useStorage(
window.localStorage,
STORAGE_KEYS_PREFIX
);

View File

@ -5,18 +5,8 @@ import logo from "./assets/chessh_sm.svg";
import { useAuthContext } from "./context/auth_context"; import { useAuthContext } from "./context/auth_context";
export const Root = () => { export const Root = () => {
const { signedIn, setUserId, setSignedIn, setSessionOver } = useAuthContext(); const { signedIn, setUserId, setSignedIn, setSessionOver, signOut } =
useAuthContext();
const signOut = () => {
fetch("/api/player/logout", {
method: "GET",
credentials: "same-origin",
}).then(() => {
setSignedIn(false);
setUserId(null);
setSessionOver(new Date());
});
};
return ( return (
<> <>

View File

@ -1,33 +1,23 @@
import { useEffect, useCallback } from "react"; import { useEffect } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { import { useAuthContext } from "../context/auth_context";
useAuthContext,
DEFAULT_EXPIRY_TIME_MS,
} from "../context/auth_context";
export const AuthSuccessful = () => { export const AuthSuccessful = () => {
const { const { player, setPlayer, signedIn, setSignedIn, setSessionOver } =
username, useAuthContext();
userId,
sessionOver,
signedIn,
setSignedIn,
setUserId,
setUsername,
setSessionOver,
} = useAuthContext();
useEffect(() => { useEffect(() => {
fetch("/api/player/me", { fetch("/api/player/token/me", {
credentials: "same-origin", credentials: "same-origin",
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((player) => { .then(({ player, expiration }) => {
setSignedIn(!!player); setSignedIn(!!player);
setUserId(player.id); setPlayer(player);
setUsername(player.username); setSessionOver(expiration);
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
if (signedIn) { if (signedIn) {
@ -35,7 +25,7 @@ export const AuthSuccessful = () => {
<> <>
<h1>Authentication Successful</h1> <h1>Authentication Successful</h1>
<div> <div>
<span>Hello there, {username || ""}! </span> <span>Hello there, {player?.username || ""}! </span>
<Link to="/home" className="button"> <Link to="/home" className="button">
Go Home{" "} Go Home{" "}
</Link> </Link>

View File

@ -4,7 +4,10 @@ import { Link } from "react-router-dom";
import { useAuthContext } from "../context/auth_context"; import { useAuthContext } from "../context/auth_context";
export const Home = () => { export const Home = () => {
const { username, signedIn } = useAuthContext(); const {
player: { username },
signedIn,
} = useAuthContext();
if (signedIn) { if (signedIn) {
const sshConfig = `Host chessh const sshConfig = `Host chessh

View File

@ -67,7 +67,7 @@ const AddKeyButton = ({ onSave }) => {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
key: key.value, key: key.value.trim(),
name: name.value, name: name.value,
}), }),
}) })
@ -77,7 +77,6 @@ const AddKeyButton = ({ onSave }) => {
if (onSave) { if (onSave) {
onSave(); onSave();
} }
setDefaults();
close(); close();
} else if (d.errors) { } else if (d.errors) {
if (typeof d.errors === "object") { if (typeof d.errors === "object") {
@ -158,7 +157,9 @@ const AddKeyButton = ({ onSave }) => {
}; };
export const Keys = () => { export const Keys = () => {
const { userId } = useAuthContext(); const {
player: { id: userId },
} = useAuthContext();
const [keys, setKeys] = useState(null); const [keys, setKeys] = useState(null);
const refreshKeys = useCallback( const refreshKeys = useCallback(
@ -175,9 +176,8 @@ export const Keys = () => {
} }
}, [userId, refreshKeys]); }, [userId, refreshKeys]);
if (!keys) { if (!keys) return <p>Loading...</p>;
return <p>Loading...</p>;
}
if (Array.isArray(keys)) { if (Array.isArray(keys)) {
return ( return (
<> <>

View File

@ -35,16 +35,23 @@ defmodule Chessh.SSH.Client.Game do
def init([ def init([
%State{ %State{
color: color, color: color,
game: %Game{dark_player_id: dark_player_id, light_player_id: light_player_id} game: %Game{dark_player_id: dark_player_id, light_player_id: light_player_id},
player_session: %{player_id: player_id}
} = state } = state
| tail | tail
]) ])
when is_nil(color) do when is_nil(color) do
{is_dark, is_light} = {player_id == dark_player_id, player_id == light_player_id}
new_state = new_state =
if is_dark || is_light do
%State{state | color: if(is_light, do: :light, else: :dark)}
else
case {is_nil(dark_player_id), is_nil(light_player_id)} do case {is_nil(dark_player_id), is_nil(light_player_id)} do
{true, false} -> %State{state | color: :dark} {true, false} -> %State{state | color: :dark}
{false, true} -> %State{state | color: :light} {false, true} -> %State{state | color: :light}
{_, _} -> %State{state | color: Enum.random([:light, :dark])} {_, _} -> %State{state | color: :light}
end
end end
init([new_state | tail]) init([new_state | tail])
@ -89,18 +96,22 @@ defmodule Chessh.SSH.Client.Game do
end end
binbo_pid = initialize_game(game_id, fen) binbo_pid = initialize_game(game_id, fen)
send(client_pid, {:send_to_ssh, Utils.clear_codes()})
new_game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player]) new_game = Repo.get(Game, game_id) |> Repo.preload([:light_player, :dark_player])
new_state = %State{ player_color =
if(new_game.light_player_id == player_session.player_id, do: :light, else: :dark)
send(client_pid, {:send_to_ssh, Utils.clear_codes()})
{:ok,
%State{
state state
| binbo_pid: binbo_pid, | binbo_pid: binbo_pid,
color: if(new_game.light_player_id == player_session.player_id, do: :light, else: :dark), color: player_color,
game: new_game game: new_game,
} flipped: player_color == :dark
}}
{:ok, new_state}
end end
def init([ def init([

View File

@ -39,21 +39,8 @@ defmodule Chessh.Web.Endpoint do
{status, body} = {status, body} =
create_player_from_github_response(resp, github_user_api_url, github_user_agent) create_player_from_github_response(resp, github_user_api_url, github_user_agent)
case body do
%{jwt: token} ->
client_redirect_location =
Application.get_env(:chessh, Web)[:client_redirect_after_successful_sign_in]
conn conn
|> put_resp_cookie("jwt", token) |> assign_jwt_and_redirect_or_encode(status, body)
|> put_resp_header("location", client_redirect_location)
|> send_resp(301, '')
_ ->
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(body))
end
end end
put "/player/password" do put "/player/password" do
@ -115,8 +102,7 @@ defmodule Chessh.Web.Endpoint do
end end
conn conn
|> put_resp_content_type("application/json") |> assign_jwt_and_redirect_or_encode(status, body)
|> send_resp(status, Jason.encode!(body))
end end
get "/player/logout" do get "/player/logout" do
@ -171,12 +157,15 @@ defmodule Chessh.Web.Endpoint do
|> send_resp(status, Jason.encode!(body)) |> send_resp(status, Jason.encode!(body))
end end
get "/player/me" do get "/player/token/me" do
player = get_player_from_jwt(conn) {:ok, jwt} = Token.verify_and_validate(get_jwt(conn))
%{"uid" => player_id, "exp" => expiration} = jwt
player = Repo.get(Player, player_id)
conn conn
|> put_resp_content_type("application/json") |> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(player)) |> send_resp(200, Jason.encode!(%{player: player, expiration: expiration * 1000}))
end end
get "/player/:id/keys" do get "/player/:id/keys" do
@ -242,19 +231,39 @@ defmodule Chessh.Web.Endpoint do
) )
end end
defp get_player_from_jwt(conn) do defp get_jwt(conn) do
auth_header = auth_header =
Enum.find_value(conn.req_headers, fn {header, value} -> Enum.find_value(conn.req_headers, fn {header, value} ->
if header === "authorization", do: value if header === "authorization", do: value
end) end)
jwt = if auth_header, do: auth_header, else: Map.get(fetch_cookies(conn).cookies, "jwt") if auth_header, do: auth_header, else: Map.get(fetch_cookies(conn).cookies, "jwt")
end
{:ok, %{"uid" => uid}} = Token.verify_and_validate(jwt) defp get_player_from_jwt(conn) do
{:ok, %{"uid" => uid}} = Token.verify_and_validate(get_jwt(conn))
Repo.get(Player, uid) Repo.get(Player, uid)
end end
defp assign_jwt_and_redirect_or_encode(conn, status, body) do
case body do
%{jwt: token} ->
client_redirect_location =
Application.get_env(:chessh, Web)[:client_redirect_after_successful_sign_in]
conn
|> put_resp_cookie("jwt", token)
|> put_resp_header("location", client_redirect_location)
|> send_resp(301, '')
_ ->
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(body))
end
end
defp create_player_from_github_response(resp, github_user_api_url, github_user_agent) do defp create_player_from_github_response(resp, github_user_api_url, github_user_agent) do
case resp do case resp do
%{"access_token" => access_token} -> %{"access_token" => access_token} ->