From 68149c19760e5051fcf7abc095c47604200b2578 Mon Sep 17 00:00:00 2001 From: MiguelMLorente Date: Sat, 6 Dec 2025 23:04:33 +0100 Subject: [PATCH] Implement authentication with JWT --- src/App.tsx | 89 +++++++++++---------------- src/api/client.ts | 63 ++++++++++++------- src/context/logged-in-page-context.ts | 8 --- src/context/pre-login-page-context.ts | 8 --- src/pages/Buy.tsx | 6 +- src/pages/BuyReturn.tsx | 5 +- src/pages/Login.tsx | 8 +-- src/pages/Register.tsx | 7 +-- src/pages/auth/AuthenticatedRoute.tsx | 13 ++++ src/pages/signup/SessionAction.tsx | 7 +-- src/pages/signup/SignUpPage.tsx | 8 +-- 11 files changed, 102 insertions(+), 120 deletions(-) delete mode 100644 src/context/logged-in-page-context.ts delete mode 100644 src/context/pre-login-page-context.ts create mode 100644 src/pages/auth/AuthenticatedRoute.tsx diff --git a/src/App.tsx b/src/App.tsx index 7bc067b..47472b6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,63 +5,44 @@ import { Outlet, Route, Routes } from "react-router"; import { SignUpPage } from "./pages/signup/SignUpPage.tsx"; import { Register } from "./pages/Register.tsx"; import { Buy } from "./pages/Buy.tsx"; -import { useState } from "react"; -import { PreLoginPageContext } from "./context/pre-login-page-context.ts"; -import { LoggedInPageContext } from "./context/logged-in-page-context.ts"; import { NotFound } from "./pages/NotFound.tsx"; import { BuyReturn } from "./pages/BuyReturn.tsx"; import { Grid, Paper } from "@mui/material"; +import { AuthenticatedRoute } from "./pages/auth/AuthenticatedRoute.tsx"; -function App() { - const [userId, setUserId] = useState( - window.localStorage.getItem("paysplit-user-id"), - ); - const storeUserId = (id: string) => { - window.localStorage.setItem("paysplit-user-id", id.toString()); - setUserId(id); - }; - - return ( - - - App logo - - - - - } - > - } /> - } /> - - - - - } - > - } /> - } /> - } /> - - } /> - - - - ); -} +const App = () => ( + + + App logo + + } /> + } /> + + + + } + > + } /> + } /> + } /> + + } /> + + + +); export default App; diff --git a/src/api/client.ts b/src/api/client.ts index 1975e07..ca79cad 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,28 +1,49 @@ import axios, { type AxiosResponse } from "axios"; +const setJwtToken = (token: string) => + window.localStorage.setItem("paysplit-jwt", token); + +export const getJwtToken = () => window.localStorage.getItem("paysplit-jwt"); + +const getJwtHeader = () => { + const token = getJwtToken(); + if (!token) { + throw new Error("Unauthenticated"); + } + return { + headers: { + authorization: `Bearer ${token}`, + }, + }; +}; + interface AccessResponse { - userId: string; + jwtToken: string; } -export const login = (name: string, password: string) => - axios.post("/access/login", { +export const login = async (name: string, password: string) => { + const response = (await axios.post("/access/login", { name, password, - }) as Promise>; + })) as AxiosResponse; + setJwtToken(response.data.jwtToken); +}; -export const registerUser = (name: string, password: string) => - axios.post("/access/register", { +export const registerUser = async (name: string, password: string) => { + const response = (await axios.post("/access/register", { name, password, - }) as Promise>; + })) as AxiosResponse; + setJwtToken(response.data.jwtToken); +}; -export const startBuyFlow = (userId: string, quantity: number) => - axios.post("/buy/start", { userId, quantity }) as Promise< +export const startBuyFlow = (quantity: number) => + axios.post("/buy/start", { quantity }, getJwtHeader()) as Promise< AxiosResponse<{ url: string }, unknown, {}> >; -export const completeBuyFlow = (userId: string) => - axios.post("/buy/complete", { userId }) as Promise< +export const completeBuyFlow = () => + axios.post("/buy/complete", {}, getJwtHeader()) as Promise< AxiosResponse<{ url: string }, unknown, {}> >; @@ -35,21 +56,21 @@ export interface Session { date: string; } -export const getAllSessions = (userId: string) => +export const getAllSessions = () => ( - axios.get(`/session?userId=${userId}`) as Promise> + axios.get("/session", getJwtHeader()) as Promise> ).then((response) => response.data); -export const joinSession = (userId: string, sessionId: string) => +export const joinSession = (sessionId: string) => ( - axios.post("/session/join", { userId, sessionId }) as Promise< + axios.post("/session/join", { sessionId }, getJwtHeader()) as Promise< AxiosResponse > ).then((response) => response.data); -export const leaveSession = (userId: string, sessionId: string) => +export const leaveSession = (sessionId: string) => ( - axios.post("/session/leave", { userId, sessionId }) as Promise< + axios.post("/session/leave", { sessionId }, getJwtHeader()) as Promise< AxiosResponse > ).then((response) => response.data); @@ -61,7 +82,7 @@ export interface Tokens { used: number; } -export const getAvailableTokenCount = (userId: string) => - ( - axios.get(`/tokens?userId=${userId}`) as Promise> - ).then((response) => response.data); +export const getAvailableTokenCount = () => + (axios.get("/tokens", getJwtHeader()) as Promise>).then( + (response) => response.data, + ); diff --git a/src/context/logged-in-page-context.ts b/src/context/logged-in-page-context.ts deleted file mode 100644 index b3f590b..0000000 --- a/src/context/logged-in-page-context.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createContext } from "react"; - -interface LoggedInPageContextProps { - userId: string; -} - -export const LoggedInPageContext = - createContext(null); diff --git a/src/context/pre-login-page-context.ts b/src/context/pre-login-page-context.ts deleted file mode 100644 index 353280b..0000000 --- a/src/context/pre-login-page-context.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createContext } from "react"; - -interface PreLoginPageContextProps { - setUserId: (id: string) => void; -} - -export const PreLoginPageContext = - createContext(null); diff --git a/src/pages/Buy.tsx b/src/pages/Buy.tsx index 38e154b..3dc6673 100644 --- a/src/pages/Buy.tsx +++ b/src/pages/Buy.tsx @@ -1,6 +1,5 @@ -import { useContext, useState } from "react"; +import { useState } from "react"; import { startBuyFlow } from "../api/client"; -import { LoggedInPageContext } from "../context/logged-in-page-context"; import { Button, FormControlLabel, @@ -14,10 +13,9 @@ import { useNavigate } from "react-router"; export const Buy = () => { const [quantity, setQuantity] = useState(0); - const { userId } = useContext(LoggedInPageContext)!; const navigate = useNavigate(); const handleBuyFlow = () => - startBuyFlow(userId, quantity).then( + startBuyFlow(quantity).then( (response) => (window.location.href = response.data.url), ); diff --git a/src/pages/BuyReturn.tsx b/src/pages/BuyReturn.tsx index 005e4d5..d9591a7 100644 --- a/src/pages/BuyReturn.tsx +++ b/src/pages/BuyReturn.tsx @@ -1,11 +1,8 @@ -import { useContext } from "react"; import { completeBuyFlow } from "../api/client"; -import { LoggedInPageContext } from "../context/logged-in-page-context"; import { CircularProgress, Grid, Typography } from "@mui/material"; export const BuyReturn = () => { - const { userId } = useContext(LoggedInPageContext)!; - completeBuyFlow(userId).then( + completeBuyFlow().then( (response) => (window.location.href = response.data.url), ); diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index c7aa55f..6ca80e4 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,7 +1,6 @@ -import { useContext, useState } from "react"; +import { useState } from "react"; import { login } from "../api/client"; import { useNavigate } from "react-router"; -import { PreLoginPageContext } from "../context/pre-login-page-context"; import { Button, Checkbox, @@ -15,13 +14,10 @@ export const Login = () => { const [userName, setUserName] = useState(""); const [password, setPassword] = useState(""); const [rememberMe, setRememberMe] = useState(false); - - const { setUserId } = useContext(PreLoginPageContext)!; const navigate = useNavigate(); const handleLoginFlow = () => - login(userName, password).then((response) => { - setUserId(response.data.userId); + login(userName, password).then(() => { navigate("/signup"); }); diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 571a029..cbd52c5 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,8 +1,7 @@ -import { useContext, useState } from "react"; +import { useState } from "react"; import * as zod from "zod"; import { registerUser } from "../api/client"; import { useNavigate } from "react-router"; -import { PreLoginPageContext } from "../context/pre-login-page-context"; import { Alert, Button, @@ -16,7 +15,6 @@ export const Register = () => { const [userName, setUserName] = useState(""); const [password, setPassword] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); - const { setUserId } = useContext(PreLoginPageContext)!; const navigate = useNavigate(); const PASSWORD_MAX_LENGTH = 64; @@ -51,8 +49,7 @@ export const Register = () => { } const handleRegisterFlow = () => - registerUser(userName, password).then((response) => { - setUserId(response.data.userId); + registerUser(userName, password).then(() => { navigate("/signup"); }); diff --git a/src/pages/auth/AuthenticatedRoute.tsx b/src/pages/auth/AuthenticatedRoute.tsx new file mode 100644 index 0000000..9a887e0 --- /dev/null +++ b/src/pages/auth/AuthenticatedRoute.tsx @@ -0,0 +1,13 @@ +import { getJwtToken } from "../../api/client"; +import { useNavigate } from "react-router"; + +export const AuthenticatedRoute = (props: { children: React.ReactNode }) => { + const navigate = useNavigate(); + const authToken = getJwtToken(); + + if (!authToken) { + navigate("/login"); + } + + return props.children; +}; diff --git a/src/pages/signup/SessionAction.tsx b/src/pages/signup/SessionAction.tsx index f3cf974..a12056d 100644 --- a/src/pages/signup/SessionAction.tsx +++ b/src/pages/signup/SessionAction.tsx @@ -5,8 +5,6 @@ import { type Session, type Tokens, } from "../../api/client"; -import { LoggedInPageContext } from "../../context/logged-in-page-context"; -import { useContext } from "react"; import { Async } from "react-async"; import { useNavigate } from "react-router"; @@ -15,7 +13,6 @@ export const SessionAction = (props: { tokensPromise: Promise; }) => { const { session, tokensPromise } = props; - const { userId } = useContext(LoggedInPageContext)!; const navigate = useNavigate(); if (session.status !== "OPEN") { @@ -38,7 +35,7 @@ export const SessionAction = (props: { color="primary" sx={{ width: "100%" }} onClick={async () => { - await leaveSession(userId, session.id); + await leaveSession(session.id); window.location.reload(); }} > @@ -83,7 +80,7 @@ export const SessionAction = (props: { color="primary" sx={{ width: "100%" }} onClick={async () => { - await joinSession(userId, session.id); + await joinSession(session.id); window.location.reload(); }} > diff --git a/src/pages/signup/SignUpPage.tsx b/src/pages/signup/SignUpPage.tsx index 45da4b6..dc68595 100644 --- a/src/pages/signup/SignUpPage.tsx +++ b/src/pages/signup/SignUpPage.tsx @@ -1,5 +1,5 @@ import { Alert, Grid, Paper, Typography } from "@mui/material"; -import { useContext, useMemo } from "react"; +import { useMemo } from "react"; import { Async } from "react-async"; import { getAllSessions, @@ -7,14 +7,12 @@ import { type Session, type Tokens, } from "../../api/client"; -import { LoggedInPageContext } from "../../context/logged-in-page-context"; import { SessionPicker } from "./SessionPicker"; import { TokensSummary } from "./TokensSummary"; export const SignUpPage = () => { - const { userId } = useContext(LoggedInPageContext)!; - const sessionsPromise = useMemo(() => getAllSessions(userId), []); - const tokensPromise = getAvailableTokenCount(userId); + const sessionsPromise = useMemo(getAllSessions, []); + const tokensPromise = getAvailableTokenCount(); return (