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