Initial commit : monorepo seiche (serveur Fastify + web Vite/React, Docker, config VS Code)

This commit is contained in:
bot-claude
2026-08-25 09:55:13 +02:00
commit 5174c37a31
61 changed files with 7117 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Séiche</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{
"name": "seiche-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@tanstack/react-query": "^5.62.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router": "^7.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.5.0",
"jsdom": "^26.1.0",
"tailwindcss": "^4.1.0",
"typescript": "^5.9.3",
"vite": "^7.0.0",
"vitest": "^4.1.11"
}
}
+21
View File
@@ -0,0 +1,21 @@
import { Navigate, Route, Routes } from "react-router";
import RequireAuth from "./components/RequireAuth";
import DashboardPage from "./pages/DashboardPage";
import LoginPage from "./pages/LoginPage";
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<RequireAuth>
<DashboardPage />
</RequireAuth>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { type ReactNode, useEffect } from "react";
import { useNavigate } from "react-router";
import { useSession } from "../lib/session";
/**
* Garde de route : sans session valide, redirect `/login`.
* Pendant la vérification, un écran de chargement (pas de flash de contenu protégé).
*/
export default function RequireAuth({ children }: { children: ReactNode }) {
const { isPending, isError } = useSession();
const navigate = useNavigate();
useEffect(() => {
if (isError) navigate("/login", { replace: true });
}, [isError, navigate]);
if (isPending) {
return (
<main className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100">
<p>Chargement…</p>
</main>
);
}
if (isError) return null;
return <>{children}</>;
}
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+79
View File
@@ -0,0 +1,79 @@
/**
* Client API minimal (fetch + cookies).
* Contrat serveur : server/src/routes/auth.ts (mêmes codes d'erreur, mêmes shapes).
*/
export interface ApiUser {
username: string;
}
export interface AuthSession {
user: ApiUser;
csrfToken: string;
csrfHeader: string;
sessionExpiresAt: string;
}
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
public readonly retryAfterMs?: number,
) {
super(code);
}
}
/** Clé TanStack Query du cache de session (partagée entre login et guard). */
export const sessionQueryKey = ["session"] as const;
interface ErrorBody {
error?: unknown;
retryAfterMs?: unknown;
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
...init,
credentials: "same-origin",
headers: { "content-type": "application/json", ...init.headers },
});
let body: ErrorBody | T = {};
try {
body = (await res.json()) as ErrorBody | T;
} catch {
// corps vide / non JSON : on reste sur {}
}
if (!res.ok) {
const record = body as ErrorBody;
const retryAfterMs = typeof record.retryAfterMs === "number" ? record.retryAfterMs : undefined;
throw new ApiError(
res.status,
typeof record.error === "string" ? record.error : "unknown",
retryAfterMs,
);
}
return body as T;
}
export const api = {
login(username: string, password: string): Promise<AuthSession> {
return request<AuthSession>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
},
session(): Promise<AuthSession> {
return request<AuthSession>("/api/auth/session");
},
logout(session: AuthSession): Promise<{ ok: true }> {
return request<{ ok: true }>("/api/auth/logout", {
method: "POST",
headers: { [session.csrfHeader]: session.csrfToken },
});
},
};
+17
View File
@@ -0,0 +1,17 @@
import { QueryClient } from "@tanstack/react-query";
import { ApiError } from "./api";
/** Factory partagée main.tsx ↔ tests : mêmes options par défaut des queries. */
export function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
// Pas de retry sur les 4xx (401 session morte, 400 corps invalide…) : ça ne passera pas.
retry: (failureCount, error) =>
!(error instanceof ApiError && error.status >= 400 && error.status < 500) &&
failureCount < 3,
},
},
});
}
+12
View File
@@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { type AuthSession, api, sessionQueryKey } from "./api";
/**
* Session courante (cache partagé : la login seede, le guard lit, le dashboard consomme).
*/
export function useSession(): ReturnType<typeof useQuery<AuthSession>> {
return useQuery<AuthSession>({
queryKey: sessionQueryKey,
queryFn: api.session,
});
}
+22
View File
@@ -0,0 +1,22 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App";
import { createQueryClient } from "./lib/queryClient";
import "./index.css";
const queryClient = createQueryClient();
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("élément #root introuvable");
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);
+45
View File
@@ -0,0 +1,45 @@
import { useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { api, sessionQueryKey } from "../lib/api";
import { useSession } from "../lib/session";
export default function DashboardPage() {
const { data: session } = useSession();
const queryClient = useQueryClient();
const navigate = useNavigate();
// Le guard garantit une session valide ici ; null = état transitoire impossible.
if (!session) return null;
const s = session; // const resserré : accessible depuis le closure sans renarrowing
async function handleLogout() {
try {
await api.logout(s);
} catch {
// Session déjà morte côté serveur : on continue la déconnexion locale.
}
queryClient.removeQueries({ queryKey: sessionQueryKey });
navigate("/login");
}
return (
<main className="min-h-screen bg-slate-950 p-8 text-slate-100">
<div className="mx-auto max-w-xl space-y-6">
<h1 className="text-2xl font-semibold">Tableau de bord — Séiche</h1>
<p>
Connecté en tant que <strong>{session.user.username}</strong>
</p>
<p className="text-sm text-slate-400">
Session expire le {new Date(session.sessionExpiresAt).toLocaleString("fr-FR")}.
</p>
<button
type="button"
onClick={handleLogout}
className="rounded bg-red-600 px-3 py-2 font-medium hover:bg-red-500"
>
Se déconnecter
</button>
</div>
</main>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { type FormEvent, useState } from "react";
import { useNavigate } from "react-router";
import { ApiError, api, sessionQueryKey } from "../lib/api";
function errorMessage(err: unknown): string {
if (err instanceof ApiError) {
if (err.code === "invalid_credentials") return "Identifiants invalides.";
if (err.code === "too_many_attempts") {
const s = Math.ceil((err.retryAfterMs ?? 60_000) / 1000);
return `Trop de tentatives, réessayez dans ${s} s.`;
}
if (err.status === 429) return "Trop de tentatives, patientez un instant.";
if (err.status >= 500) return "Erreur serveur, réessayez plus tard.";
}
return "Connexion impossible, réessayez.";
}
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: ({ username, password }: { username: string; password: string }) =>
api.login(username, password),
onSuccess: (session) => {
queryClient.setQueryData(sessionQueryKey, session);
navigate("/");
},
onError: (err) => setFormError(errorMessage(err)),
});
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setFormError(null);
if (mutation.isPending) return;
if (username.length < 1 || username.length > 64) {
setFormError("Nom d'utilisateur : entre 1 et 64 caractères.");
return;
}
if (password.length < 1 || password.length > 256) {
setFormError("Mot de passe : entre 1 et 256 caractères.");
return;
}
mutation.mutate({ username, password });
}
return (
<main className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100">
<form
onSubmit={handleSubmit}
className="w-80 space-y-4 rounded-lg border border-slate-800 bg-slate-900 p-6"
>
<h1 className="text-xl font-semibold">Connexion — Séiche</h1>
<label className="block text-sm">
<span className="text-slate-400">Nom d'utilisateur</span>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
maxLength={64}
className="mt-1 block w-full rounded border border-slate-700 bg-slate-950 px-3 py-2"
/>
</label>
<label className="block text-sm">
<span className="text-slate-400">Mot de passe</span>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
maxLength={256}
className="mt-1 block w-full rounded border border-slate-700 bg-slate-950 px-3 py-2"
/>
</label>
{formError !== null && (
<p role="alert" className="text-sm text-red-400">
{formError}
</p>
)}
<button
type="submit"
disabled={mutation.isPending}
className="w-full rounded bg-sky-600 px-3 py-2 font-medium hover:bg-sky-500 disabled:opacity-50"
>
{mutation.isPending ? "Connexion…" : "Se connecter"}
</button>
</form>
</main>
);
}
+8
View File
@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// Auto-cleanup désactivé (vitest sans `globals: true`) → cleanup explicite.
afterEach(() => {
cleanup();
});
+91
View File
@@ -0,0 +1,91 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import App from "../src/App";
import type { AuthSession } from "../src/lib/api";
import { createQueryClient } from "../src/lib/queryClient";
const session: AuthSession = {
user: { username: "nicolas" },
csrfToken: "csrf-token",
csrfHeader: "x-csrf-token",
sessionExpiresAt: new Date(Date.now() + 3_600_000).toISOString(),
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function mockApi(handler: (url: string, init?: RequestInit) => Response) {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => handler(String(input), init)),
);
}
function renderApp(path: string) {
return render(
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={[path]}>
<App />
</MemoryRouter>
</QueryClientProvider>,
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("smoke front", () => {
it("rend le formulaire de login", () => {
mockApi(() => jsonResponse({}));
renderApp("/login");
expect(screen.getByLabelText(/nom d'utilisateur/i)).toBeInTheDocument();
expect(screen.getByLabelText(/mot de passe/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /se connecter/i })).toBeInTheDocument();
});
it("redirige vers /login sans session valide", async () => {
mockApi((url) =>
url.includes("/auth/session")
? jsonResponse({ error: "not_authenticated" }, 401)
: jsonResponse({}, 404),
);
renderApp("/");
await waitFor(() => {
expect(screen.getByRole("button", { name: /se connecter/i })).toBeInTheDocument();
});
});
it("connecte l'utilisateur et affiche le dashboard", async () => {
mockApi((url, init) =>
url.includes("/auth/login") && init?.method === "POST"
? jsonResponse(session)
: jsonResponse({ error: "not_authenticated" }, 401),
);
renderApp("/login");
fireEvent.change(screen.getByLabelText(/nom d'utilisateur/i), {
target: { value: "nicolas" },
});
fireEvent.change(screen.getByLabelText(/mot de passe/i), {
target: { value: "secret" },
});
const form = screen
.getByRole("button", { name: /se connecter/i })
.closest("form") as HTMLFormElement;
fireEvent.submit(form);
await waitFor(() => {
expect(screen.getByText(/tableau de bord/i)).toBeInTheDocument();
});
expect(screen.getByText("nicolas")).toBeInTheDocument();
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "tests", "vite.config.ts"]
}
+23
View File
@@ -0,0 +1,23 @@
/// <reference types="vitest/config" />
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
// Dev : l'API tourne sur le port du serveur (défaut 8787, voir server/src/config.ts).
"/api": "http://localhost:8787",
},
},
build: {
outDir: "dist",
},
test: {
environment: "jsdom",
setupFiles: "./tests/setup.ts",
passWithNoTests: true,
},
});