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
+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>
);
}