feat(api): add auth middleware to protect game endpoints

- Add requireAuth middleware using Better Auth session validation
- Apply to all game routes (start, answer)
- Unauthenticated requests return 401
This commit is contained in:
lila 2026-04-12 13:38:32 +02:00
parent 91a3112d8b
commit a3685a9e68
13 changed files with 196 additions and 24 deletions

View file

@ -0,0 +1,7 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: "http://localhost:3000",
});
export const { signIn, signOut, useSession } = authClient;

View file

@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as PlayRouteImport } from './routes/play'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
@ -18,6 +19,11 @@ const PlayRoute = PlayRouteImport.update({
path: '/play',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AboutRoute = AboutRouteImport.update({
id: '/about',
path: '/about',
@ -32,30 +38,34 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/play': typeof PlayRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/about' | '/play'
fullPaths: '/' | '/about' | '/login' | '/play'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/about' | '/play'
id: '__root__' | '/' | '/about' | '/play'
to: '/' | '/about' | '/login' | '/play'
id: '__root__' | '/' | '/about' | '/login' | '/play'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
LoginRoute: typeof LoginRoute
PlayRoute: typeof PlayRoute
}
@ -68,6 +78,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PlayRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/about': {
id: '/about'
path: '/about'
@ -88,6 +105,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
LoginRoute: LoginRoute,
PlayRoute: PlayRoute,
}
export const routeTree = rootRouteImport

View file

@ -1,20 +1,51 @@
import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
import {
createRootRoute,
Link,
Outlet,
useNavigate,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { useSession, signOut } from "../lib/auth-client";
const RootLayout = () => (
<>
<div className="p-2 flex gap-2">
<Link to="/" className="[&.active]:font-bold">
Home
</Link>{" "}
<Link to="/about" className="[&.active]:font-bold">
About
</Link>
</div>
<hr />
<Outlet />
<TanStackRouterDevtools />
</>
);
const RootLayout = () => {
const { data: session } = useSession();
const navigate = useNavigate();
return (
<>
<div className="p-2 flex gap-2 items-center">
<Link to="/" className="[&.active]:font-bold">
Home
</Link>
<Link to="/about" className="[&.active]:font-bold">
About
</Link>
<div className="ml-auto">
{session ? (
<button
className="text-sm text-gray-600 hover:text-gray-900"
onClick={async () => {
await signOut();
navigate({ to: "/" });
}}
>
Sign out ({session.user.name})
</button>
) : (
<Link
to="/login"
className="text-sm text-blue-600 hover:text-blue-800"
>
Sign in
</Link>
)}
</div>
</div>
<hr />
<Outlet />
<TanStackRouterDevtools />
</>
);
};
export const Route = createRootRoute({ component: RootLayout });

View file

@ -0,0 +1,44 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { signIn, useSession } from "../lib/auth-client";
const LoginPage = () => {
const { data: session, isPending } = useSession();
const navigate = useNavigate();
if (isPending) return <div className="p-4">Loading...</div>;
if (session) {
navigate({ to: "/" });
return null;
}
return (
<div className="flex flex-col items-center justify-center gap-4 p-8">
<h1 className="text-2xl font-bold">Sign in to Glossa</h1>
<button
className="w-64 rounded bg-gray-800 px-4 py-2 text-white hover:bg-gray-700"
onClick={() =>
signIn.social({
provider: "github",
callbackURL: "http://localhost:5173",
})
}
>
Continue with GitHub
</button>
<button
className="w-64 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-500"
onClick={() =>
signIn.social({
provider: "google",
callbackURL: "http://localhost:5173",
})
}
>
Continue with Google
</button>
</div>
);
};
export const Route = createFileRoute("/login")({ component: LoginPage });

View file

@ -1,9 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useState, useCallback } from "react";
import type { GameSession, GameRequest, AnswerResult } from "@glossa/shared";
import { QuestionCard } from "../components/game/QuestionCard";
import { ScoreScreen } from "../components/game/ScoreScreen";
import { GameSetup } from "../components/game/GameSetup";
import { authClient } from "../lib/auth-client";
function Play() {
const [gameSession, setGameSession] = useState<GameSession | null>(null);
@ -105,4 +106,12 @@ function Play() {
);
}
export const Route = createFileRoute("/play")({ component: Play });
export const Route = createFileRoute("/play")({
component: Play,
beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) {
throw redirect({ to: "/login" });
}
},
});

View file

@ -2,6 +2,9 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"composite": false,
"declaration": false,
"declarationMap": false,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"module": "ESNext",
@ -9,7 +12,7 @@
"noEmit": true,
"target": "ES2023",
"types": ["vite/client", "vitest/globals"],
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
},
"include": ["src", "vitest.config.ts"]
"include": ["src", "vitest.config.ts"],
}