feat(web): TanStack Query hooks + session-guarded routes

Installs @tanstack/react-query and react-router-dom; adds typed query
hooks (useMe, useObjectsPage, useObject, useFieldDefinitions, useLogin,
useLogout), a QueryClient+MemoryRouter test render helper, and
RequireAuth — a layout route that blocks unauthenticated access and
redirects to /login. All 7 tests pass, typecheck/lint/build clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 22:49:55 +02:00
parent 2e4187c850
commit cf02eeb991
6 changed files with 213 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "./client";
import type { components } from "./schema";
type UserView = components["schemas"]["UserView"];
type LoginRequest = components["schemas"]["LoginRequest"];
export function useMe() {
return useQuery({
queryKey: ["me"],
queryFn: async (): Promise<UserView | null> => {
const { data, response } = await api.GET("/api/admin/me");
if (response.status === 401) return null;
if (!data) throw new Error("failed to load session");
return data;
},
retry: false,
});
}
export function useObjectsPage(limit: number, offset: number) {
return useQuery({
queryKey: ["objects", { limit, offset }],
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/objects", {
params: { query: { limit, offset } },
});
if (error || !data) throw new Error("failed to load objects");
return data;
},
});
}
export function useObject(id: string) {
return useQuery({
queryKey: ["object", id],
queryFn: async () => {
const { data, response } = await api.GET("/api/admin/objects/{id}", {
params: { path: { id } },
});
if (response.status === 404) return null;
if (!data) throw new Error("failed to load object");
return data;
},
});
}
export function useFieldDefinitions() {
return useQuery({
queryKey: ["field-definitions"],
queryFn: async () => {
const { data, error } = await api.GET("/api/admin/field-definitions");
if (error || !data) throw new Error("failed to load field definitions");
return data;
},
staleTime: 5 * 60 * 1000,
});
}
export function useLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: LoginRequest) => {
const { response } = await api.POST("/api/admin/login", { body });
if (response.status !== 204) {
throw new Error(response.status === 401 ? "invalid" : "network");
}
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }),
});
}
export function useLogout() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
await api.POST("/api/admin/logout");
},
onSuccess: () => qc.setQueryData(["me"], null),
});
}
+30
View File
@@ -0,0 +1,30 @@
import { screen, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { expect, test } from "vitest";
import { Route, Routes } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { RequireAuth } from "./require-auth";
function tree() {
return (
<Routes>
<Route path="/login" element={<div>login page</div>} />
<Route element={<RequireAuth />}>
<Route path="/objects" element={<div>secret objects</div>} />
</Route>
</Routes>
);
}
test("renders children when authenticated", async () => {
renderApp(tree(), { route: "/objects" });
expect(await screen.findByText("secret objects")).toBeInTheDocument();
});
test("redirects to /login when unauthenticated", async () => {
server.use(http.get("/api/admin/me", () => new HttpResponse(null, { status: 401 })));
renderApp(tree(), { route: "/objects" });
await waitFor(() => expect(screen.getByText("login page")).toBeInTheDocument());
});
+13
View File
@@ -0,0 +1,13 @@
import { Navigate, Outlet } from "react-router-dom";
import { useMe } from "../api/queries";
export function RequireAuth() {
const { data: user, isLoading } = useMe();
if (isLoading) return <div role="status" aria-label="loading" />;
if (!user) return <Navigate to="/login" replace />;
return <Outlet />;
}
+16
View File
@@ -0,0 +1,16 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import type { ReactElement } from "react";
import { MemoryRouter } from "react-router-dom";
import "../i18n";
export function renderApp(ui: ReactElement, { route = "/" } = {}) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}