Implement authentication with JWT

main
MiguelMLorente 2025-12-06 23:04:33 +01:00
parent 97fee33264
commit 68149c1976
11 changed files with 102 additions and 120 deletions

View File

@ -5,63 +5,44 @@ import { Outlet, Route, Routes } from "react-router";
import { SignUpPage } from "./pages/signup/SignUpPage.tsx"; import { SignUpPage } from "./pages/signup/SignUpPage.tsx";
import { Register } from "./pages/Register.tsx"; import { Register } from "./pages/Register.tsx";
import { Buy } from "./pages/Buy.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 { NotFound } from "./pages/NotFound.tsx";
import { BuyReturn } from "./pages/BuyReturn.tsx"; import { BuyReturn } from "./pages/BuyReturn.tsx";
import { Grid, Paper } from "@mui/material"; import { Grid, Paper } from "@mui/material";
import { AuthenticatedRoute } from "./pages/auth/AuthenticatedRoute.tsx";
function App() { const App = () => (
const [userId, setUserId] = useState<string | null>( <Paper
window.localStorage.getItem("paysplit-user-id"), elevation={2}
); sx={{
const storeUserId = (id: string) => { padding: "50px 30px",
window.localStorage.setItem("paysplit-user-id", id.toString()); bgcolor: "lightblue",
setUserId(id); borderRadius: 4,
}; maxWidth: "500px",
minWidth: "350px",
return ( width: "fit-content",
<Paper margin: "auto",
elevation={2} }}
sx={{ >
padding: "50px 30px", <Grid container direction="column" alignContent="center" spacing={2}>
bgcolor: "lightblue", <img className="app-logo" src={icon} alt="App logo" />
borderRadius: 4, <Routes>
maxWidth: "500px", <Route path="/login" element={<Login />} />
width: "fit-content", <Route path="/register" element={<Register />} />
margin: "auto", <Route
}} element={
> <AuthenticatedRoute>
<Grid container direction="column" alignContent="center" spacing={2}> <Outlet />
<img className="app-logo" src={icon} alt="App logo" /> </AuthenticatedRoute>
<Routes> }
<Route >
element={ <Route path="/signup" element={<SignUpPage />} />
<PreLoginPageContext.Provider value={{ setUserId: storeUserId }}> <Route path="/buy" element={<Buy />} />
<Outlet /> <Route path="/buy/return" element={<BuyReturn />} />
</PreLoginPageContext.Provider> </Route>
} <Route path="*" element={<NotFound />} />
> </Routes>
<Route path="/login" element={<Login />} /> </Grid>
<Route path="/register" element={<Register />} /> </Paper>
</Route> );
<Route
element={
<LoggedInPageContext.Provider value={{ userId: userId! }}>
<Outlet />
</LoggedInPageContext.Provider>
}
>
<Route path="/signup" element={<SignUpPage />} />
<Route path="/buy" element={<Buy />} />
<Route path="/buy/return" element={<BuyReturn />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Grid>
</Paper>
);
}
export default App; export default App;

View File

@ -1,28 +1,49 @@
import axios, { type AxiosResponse } from "axios"; 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 { interface AccessResponse {
userId: string; jwtToken: string;
} }
export const login = (name: string, password: string) => export const login = async (name: string, password: string) => {
axios.post("/access/login", { const response = (await axios.post("/access/login", {
name, name,
password, password,
}) as Promise<AxiosResponse<AccessResponse, unknown, {}>>; })) as AxiosResponse<AccessResponse>;
setJwtToken(response.data.jwtToken);
};
export const registerUser = (name: string, password: string) => export const registerUser = async (name: string, password: string) => {
axios.post("/access/register", { const response = (await axios.post("/access/register", {
name, name,
password, password,
}) as Promise<AxiosResponse<AccessResponse, unknown, {}>>; })) as AxiosResponse<AccessResponse>;
setJwtToken(response.data.jwtToken);
};
export const startBuyFlow = (userId: string, quantity: number) => export const startBuyFlow = (quantity: number) =>
axios.post("/buy/start", { userId, quantity }) as Promise< axios.post("/buy/start", { quantity }, getJwtHeader()) as Promise<
AxiosResponse<{ url: string }, unknown, {}> AxiosResponse<{ url: string }, unknown, {}>
>; >;
export const completeBuyFlow = (userId: string) => export const completeBuyFlow = () =>
axios.post("/buy/complete", { userId }) as Promise< axios.post("/buy/complete", {}, getJwtHeader()) as Promise<
AxiosResponse<{ url: string }, unknown, {}> AxiosResponse<{ url: string }, unknown, {}>
>; >;
@ -35,21 +56,21 @@ export interface Session {
date: string; date: string;
} }
export const getAllSessions = (userId: string) => export const getAllSessions = () =>
( (
axios.get(`/session?userId=${userId}`) as Promise<AxiosResponse<Session[]>> axios.get("/session", getJwtHeader()) as Promise<AxiosResponse<Session[]>>
).then((response) => response.data); ).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<Session[]> AxiosResponse<Session[]>
> >
).then((response) => response.data); ).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<Session[]> AxiosResponse<Session[]>
> >
).then((response) => response.data); ).then((response) => response.data);
@ -61,7 +82,7 @@ export interface Tokens {
used: number; used: number;
} }
export const getAvailableTokenCount = (userId: string) => export const getAvailableTokenCount = () =>
( (axios.get("/tokens", getJwtHeader()) as Promise<AxiosResponse<Tokens>>).then(
axios.get(`/tokens?userId=${userId}`) as Promise<AxiosResponse<Tokens>> (response) => response.data,
).then((response) => response.data); );

View File

@ -1,8 +0,0 @@
import { createContext } from "react";
interface LoggedInPageContextProps {
userId: string;
}
export const LoggedInPageContext =
createContext<LoggedInPageContextProps | null>(null);

View File

@ -1,8 +0,0 @@
import { createContext } from "react";
interface PreLoginPageContextProps {
setUserId: (id: string) => void;
}
export const PreLoginPageContext =
createContext<PreLoginPageContextProps | null>(null);

View File

@ -1,6 +1,5 @@
import { useContext, useState } from "react"; import { useState } from "react";
import { startBuyFlow } from "../api/client"; import { startBuyFlow } from "../api/client";
import { LoggedInPageContext } from "../context/logged-in-page-context";
import { import {
Button, Button,
FormControlLabel, FormControlLabel,
@ -14,10 +13,9 @@ import { useNavigate } from "react-router";
export const Buy = () => { export const Buy = () => {
const [quantity, setQuantity] = useState(0); const [quantity, setQuantity] = useState(0);
const { userId } = useContext(LoggedInPageContext)!;
const navigate = useNavigate(); const navigate = useNavigate();
const handleBuyFlow = () => const handleBuyFlow = () =>
startBuyFlow(userId, quantity).then( startBuyFlow(quantity).then(
(response) => (window.location.href = response.data.url), (response) => (window.location.href = response.data.url),
); );

View File

@ -1,11 +1,8 @@
import { useContext } from "react";
import { completeBuyFlow } from "../api/client"; import { completeBuyFlow } from "../api/client";
import { LoggedInPageContext } from "../context/logged-in-page-context";
import { CircularProgress, Grid, Typography } from "@mui/material"; import { CircularProgress, Grid, Typography } from "@mui/material";
export const BuyReturn = () => { export const BuyReturn = () => {
const { userId } = useContext(LoggedInPageContext)!; completeBuyFlow().then(
completeBuyFlow(userId).then(
(response) => (window.location.href = response.data.url), (response) => (window.location.href = response.data.url),
); );

View File

@ -1,7 +1,6 @@
import { useContext, useState } from "react"; import { useState } from "react";
import { login } from "../api/client"; import { login } from "../api/client";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { PreLoginPageContext } from "../context/pre-login-page-context";
import { import {
Button, Button,
Checkbox, Checkbox,
@ -15,13 +14,10 @@ export const Login = () => {
const [userName, setUserName] = useState(""); const [userName, setUserName] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const { setUserId } = useContext(PreLoginPageContext)!;
const navigate = useNavigate(); const navigate = useNavigate();
const handleLoginFlow = () => const handleLoginFlow = () =>
login(userName, password).then((response) => { login(userName, password).then(() => {
setUserId(response.data.userId);
navigate("/signup"); navigate("/signup");
}); });

View File

@ -1,8 +1,7 @@
import { useContext, useState } from "react"; import { useState } from "react";
import * as zod from "zod"; import * as zod from "zod";
import { registerUser } from "../api/client"; import { registerUser } from "../api/client";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { PreLoginPageContext } from "../context/pre-login-page-context";
import { import {
Alert, Alert,
Button, Button,
@ -16,7 +15,6 @@ export const Register = () => {
const [userName, setUserName] = useState(""); const [userName, setUserName] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState("");
const { setUserId } = useContext(PreLoginPageContext)!;
const navigate = useNavigate(); const navigate = useNavigate();
const PASSWORD_MAX_LENGTH = 64; const PASSWORD_MAX_LENGTH = 64;
@ -51,8 +49,7 @@ export const Register = () => {
} }
const handleRegisterFlow = () => const handleRegisterFlow = () =>
registerUser(userName, password).then((response) => { registerUser(userName, password).then(() => {
setUserId(response.data.userId);
navigate("/signup"); navigate("/signup");
}); });

View File

@ -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;
};

View File

@ -5,8 +5,6 @@ import {
type Session, type Session,
type Tokens, type Tokens,
} from "../../api/client"; } from "../../api/client";
import { LoggedInPageContext } from "../../context/logged-in-page-context";
import { useContext } from "react";
import { Async } from "react-async"; import { Async } from "react-async";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
@ -15,7 +13,6 @@ export const SessionAction = (props: {
tokensPromise: Promise<Tokens>; tokensPromise: Promise<Tokens>;
}) => { }) => {
const { session, tokensPromise } = props; const { session, tokensPromise } = props;
const { userId } = useContext(LoggedInPageContext)!;
const navigate = useNavigate(); const navigate = useNavigate();
if (session.status !== "OPEN") { if (session.status !== "OPEN") {
@ -38,7 +35,7 @@ export const SessionAction = (props: {
color="primary" color="primary"
sx={{ width: "100%" }} sx={{ width: "100%" }}
onClick={async () => { onClick={async () => {
await leaveSession(userId, session.id); await leaveSession(session.id);
window.location.reload(); window.location.reload();
}} }}
> >
@ -83,7 +80,7 @@ export const SessionAction = (props: {
color="primary" color="primary"
sx={{ width: "100%" }} sx={{ width: "100%" }}
onClick={async () => { onClick={async () => {
await joinSession(userId, session.id); await joinSession(session.id);
window.location.reload(); window.location.reload();
}} }}
> >

View File

@ -1,5 +1,5 @@
import { Alert, Grid, Paper, Typography } from "@mui/material"; import { Alert, Grid, Paper, Typography } from "@mui/material";
import { useContext, useMemo } from "react"; import { useMemo } from "react";
import { Async } from "react-async"; import { Async } from "react-async";
import { import {
getAllSessions, getAllSessions,
@ -7,14 +7,12 @@ import {
type Session, type Session,
type Tokens, type Tokens,
} from "../../api/client"; } from "../../api/client";
import { LoggedInPageContext } from "../../context/logged-in-page-context";
import { SessionPicker } from "./SessionPicker"; import { SessionPicker } from "./SessionPicker";
import { TokensSummary } from "./TokensSummary"; import { TokensSummary } from "./TokensSummary";
export const SignUpPage = () => { export const SignUpPage = () => {
const { userId } = useContext(LoggedInPageContext)!; const sessionsPromise = useMemo(getAllSessions, []);
const sessionsPromise = useMemo(() => getAllSessions(userId), []); const tokensPromise = getAvailableTokenCount();
const tokensPromise = getAvailableTokenCount(userId);
return ( return (
<Grid container direction="column" spacing={2} alignItems="center"> <Grid container direction="column" spacing={2} alignItems="center">