diff --git a/web/package.json b/web/package.json index 41b9f52..f8d221b 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "dependencies": { "@base-ui/react": "^1.5.0", "@fontsource-variable/geist": "^5.2.9", + "@tanstack/react-query": "^5.101.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "i18next": "^26.3.0", @@ -24,6 +25,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^17.0.8", + "react-router-dom": "^7.16.0", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 13ac4da..7ceec43 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@fontsource-variable/geist': specifier: ^5.2.9 version: 5.2.9 + '@tanstack/react-query': + specifier: ^5.101.0 + version: 5.101.0(react@19.2.7) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -38,6 +41,9 @@ importers: react-i18next: specifier: ^17.0.8 version: 17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3) + react-router-dom: + specifier: ^7.16.0 + version: 7.16.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -922,6 +928,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/query-core@5.101.0': + resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + + '@tanstack/react-query@5.101.0': + resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2429,6 +2443,23 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-router-dom@7.16.0: + resolution: {integrity: sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.16.0: + resolution: {integrity: sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -2513,6 +2544,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-cookie-parser@3.1.0: resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} @@ -3694,6 +3728,13 @@ snapshots: tailwindcss: 4.3.0 vite: 6.4.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0) + '@tanstack/query-core@5.101.0': {} + + '@tanstack/react-query@5.101.0(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.0 + react: 19.2.7 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.7 @@ -5174,6 +5215,20 @@ snapshots: react-refresh@0.17.0: {} + react-router-dom@7.16.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router: 7.16.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-router@7.16.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + react@19.2.7: {} recast@0.23.11: @@ -5292,6 +5347,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@2.7.2: {} + set-cookie-parser@3.1.0: {} setprototypeof@1.2.0: {} diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts new file mode 100644 index 0000000..7e4c1d3 --- /dev/null +++ b/web/src/api/queries.ts @@ -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 => { + 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), + }); +} diff --git a/web/src/auth/require-auth.test.tsx b/web/src/auth/require-auth.test.tsx new file mode 100644 index 0000000..643e9cc --- /dev/null +++ b/web/src/auth/require-auth.test.tsx @@ -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 ( + + login page} /> + }> + secret objects} /> + + + ); +} + +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()); +}); diff --git a/web/src/auth/require-auth.tsx b/web/src/auth/require-auth.tsx new file mode 100644 index 0000000..cc60e88 --- /dev/null +++ b/web/src/auth/require-auth.tsx @@ -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
; + + if (!user) return ; + + return ; +} diff --git a/web/src/test/render.tsx b/web/src/test/render.tsx new file mode 100644 index 0000000..b9f5edd --- /dev/null +++ b/web/src/test/render.tsx @@ -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( + + {ui} + , + ); +}