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,23 +5,12 @@ 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<string | null>(
window.localStorage.getItem("paysplit-user-id"),
);
const storeUserId = (id: string) => {
window.localStorage.setItem("paysplit-user-id", id.toString());
setUserId(id);
};
return (
const App = () => (
<Paper
elevation={2}
sx={{
@ -29,6 +18,7 @@ function App() {
bgcolor: "lightblue",
borderRadius: 4,
maxWidth: "500px",
minWidth: "350px",
width: "fit-content",
margin: "auto",
}}
@ -36,21 +26,13 @@ function App() {
<Grid container direction="column" alignContent="center" spacing={2}>
<img className="app-logo" src={icon} alt="App logo" />
<Routes>
<Route
element={
<PreLoginPageContext.Provider value={{ setUserId: storeUserId }}>
<Outlet />
</PreLoginPageContext.Provider>
}
>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
</Route>
<Route
element={
<LoggedInPageContext.Provider value={{ userId: userId! }}>
<AuthenticatedRoute>
<Outlet />
</LoggedInPageContext.Provider>
</AuthenticatedRoute>
}
>
<Route path="/signup" element={<SignUpPage />} />
@ -61,7 +43,6 @@ function App() {
</Routes>
</Grid>
</Paper>
);
}
);
export default App;

View File

@ -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<AxiosResponse<AccessResponse, unknown, {}>>;
})) as AxiosResponse<AccessResponse>;
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<AxiosResponse<AccessResponse, unknown, {}>>;
})) as AxiosResponse<AccessResponse>;
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<AxiosResponse<Session[]>>
axios.get("/session", getJwtHeader()) as Promise<AxiosResponse<Session[]>>
).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[]>
>
).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[]>
>
).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<AxiosResponse<Tokens>>
).then((response) => response.data);
export const getAvailableTokenCount = () =>
(axios.get("/tokens", getJwtHeader()) as Promise<AxiosResponse<Tokens>>).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 { 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),
);

View File

@ -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),
);

View File

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

View File

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

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 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<Tokens>;
}) => {
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();
}}
>

View File

@ -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 (
<Grid container direction="column" spacing={2} alignItems="center">