Initial commit : monorepo seiche (serveur Fastify + web Vite/React, Docker, config VS Code)
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user