feat: scaffold UI web (React/Vite/Tailwind) + Login (v0.22.0)

Package web/ : React 19 + Vite 8 + Tailwind 4 + react-router 7 + PWA.
Tokens dark HUD Jarvis-red, client API, contexte auth JWT, shell + garde
de route, écran Login (mot de passe + TOTP). Chat/Review en stubs. Build
OK, 0 vuln. docs/ui-design.md.

Palier de risque : reversible (front statique, aucun accès infra direct).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 02:20:16 +02:00
parent e97c885ebf
commit 9de0132676
16 changed files with 7132 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
# web — UI CHLOVA
Client web/PWA du backend CHLOVA. **Phase 4.** Style dark HUD (voir
`../docs/ui-design.md`). React 19 + Vite 8 + Tailwind 4 + react-router 7.
## Dev
```bash
npm install
npm run dev # http://localhost:5173 ; proxy /api → backend :8080
npm run build # → web/dist (servi same-origin par le backend en prod)
npm run typecheck
```
Le backend doit tourner avec l'auth configurée (`CHLOVA_ADMIN_*`, voir
`orchestrator``npm run provision-auth`).
## Structure
| Fichier | Rôle |
|---|---|
| `src/api.ts` | Client API (login, chat, review, state). |
| `src/auth.tsx` | Contexte JWT (localStorage) + login/logout. |
| `src/App.tsx` | Router + shell (garde : pas de token → Login). |
| `src/pages/Login.tsx` | Login fort (mdp + TOTP). |
| `src/pages/Chat.tsx` | Conversation agent (v0.23.0). |
| `src/pages/Review.tsx` | Need-review : approuver/refuser (v0.24.0). |
## Périmètre v1
Login → Chat → Review. Voix + app RN : phases ultérieures (API commune réutilisée).
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#020617" />
<title>CHLOVA</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6732
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "chlova-web",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "CHLOVA — UI (web/PWA), client du backend CHLOVA",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"react": "19.2.7",
"react-dom": "19.2.7",
"react-router-dom": "7.18.0"
},
"devDependencies": {
"@tailwindcss/vite": "4.3.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.2",
"tailwindcss": "4.3.1",
"typescript": "5.7.3",
"vite": "8.0.16",
"vite-plugin-pwa": "1.3.0"
}
}
+42
View File
@@ -0,0 +1,42 @@
import { BrowserRouter, Routes, Route, NavLink, Navigate } from "react-router-dom";
import { useAuth } from "./auth";
import { Login } from "./pages/Login";
import { Chat } from "./pages/Chat";
import { Review } from "./pages/Review";
function Shell() {
const { logout } = useAuth();
const link = ({ isActive }: { isActive: boolean }): string =>
`px-3 py-2 rounded-md text-sm ${isActive ? "bg-surface-2 text-accent" : "text-muted hover:text-fg"}`;
return (
<div className="min-h-dvh flex flex-col">
<header className="flex items-center gap-2 border-b border-border bg-surface px-4 py-2">
<span className="font-bold tracking-wide text-accent glow mr-2">CHLOVA</span>
<nav className="flex gap-1">
<NavLink to="/chat" className={link}>Chat</NavLink>
<NavLink to="/review" className={link}>Review</NavLink>
</nav>
<button onClick={logout} className="ml-auto text-sm text-muted hover:text-fg cursor-pointer">
Déconnexion
</button>
</header>
<main className="flex-1 min-h-0">
<Routes>
<Route path="/chat" element={<Chat />} />
<Route path="/review" element={<Review />} />
<Route path="*" element={<Navigate to="/chat" replace />} />
</Routes>
</main>
</div>
);
}
export function App() {
const { token } = useAuth();
return (
<BrowserRouter>
{token ? <Shell /> : <Login />}
</BrowserRouter>
);
}
+59
View File
@@ -0,0 +1,59 @@
// Client de l'API CHLOVA. Same-origin en prod (le backend sert le SPA) ;
// en dev, Vite proxifie /api vers le backend.
export interface Asset {
id: string;
type: string;
version: string;
riskTier: string;
status: string;
createdAt: number;
expiresAt: number | null;
execCount: number;
commitLink: string | null;
docLink: string | null;
}
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
async function req<T>(path: string, token: string | null, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { "content-type": "application/json" };
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(`/api${path}`, { ...init, headers: { ...headers, ...(init?.headers as Record<string, string>) } });
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(res.status, body.error ?? `HTTP ${res.status}`);
}
return (await res.json()) as T;
}
export const api = {
login: (user: string, password: string, totp: string) =>
req<{ token: string }>("/auth/login", null, {
method: "POST",
body: JSON.stringify({ user, password, totp }),
}),
chat: (token: string, message: string) =>
req<{ reply: string }>("/chat", token, {
method: "POST",
body: JSON.stringify({ message }),
}),
review: (token: string) => req<{ assets: Asset[] }>("/review", token),
approve: (token: string, id: string) =>
req<{ asset: Asset }>(`/review/${encodeURIComponent(id)}/approve`, token, { method: "POST" }),
refuse: (token: string, id: string) =>
req<{ asset: Asset }>(`/review/${encodeURIComponent(id)}/refuse`, token, { method: "POST" }),
state: (token: string) => req<{ phase: string; tools: number }>("/state", token),
};
+38
View File
@@ -0,0 +1,38 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { api } from "./api";
/**
* Contexte d'auth : conserve le JWT (localStorage) et expose login/logout.
* Owner unique. Le JWT est court ; un 401 renvoie au login.
*/
interface AuthState {
token: string | null;
login: (user: string, password: string, totp: string) => Promise<void>;
logout: () => void;
}
const KEY = "chlova.token";
const AuthCtx = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => localStorage.getItem(KEY));
const login = async (user: string, password: string, totp: string): Promise<void> => {
const { token: t } = await api.login(user, password, totp);
localStorage.setItem(KEY, t);
setToken(t);
};
const logout = (): void => {
localStorage.removeItem(KEY);
setToken(null);
};
return <AuthCtx.Provider value={{ token, login, logout }}>{children}</AuthCtx.Provider>;
}
export function useAuth(): AuthState {
const ctx = useContext(AuthCtx);
if (!ctx) throw new Error("useAuth hors AuthProvider");
return ctx;
}
+37
View File
@@ -0,0 +1,37 @@
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Fira+Sans:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
/* Design tokens CHLOVA — Dark HUD "Jarvis-red" (voir docs/ui-design.md). */
@theme {
--color-bg: #020617;
--color-surface: #0f172a;
--color-surface-2: #1e293b;
--color-border: #334155;
--color-fg: #f8fafc;
--color-muted: #94a3b8;
--color-accent: #ff3b3b; /* identité / action primaire (glow HUD) */
--color-accent-dim: #b91c1c; /* destructif */
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-danger: #b91c1c;
--font-sans: "Fira Sans", system-ui, sans-serif;
--font-mono: "Fira Code", ui-monospace, monospace;
}
html, body, #root { height: 100%; }
body {
margin: 0;
background: var(--color-bg);
color: var(--color-fg);
font-family: var(--font-sans);
}
/* Glow minimal HUD pour l'accent. */
.glow { text-shadow: 0 0 10px rgb(255 59 59 / 0.6); }
.ring-accent:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { AuthProvider } from "./auth";
import { App } from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>,
);
+4
View File
@@ -0,0 +1,4 @@
// Vue Chat — remplie en v0.23.0.
export function Chat() {
return <div className="p-6 text-muted">Chat (v0.23.0)</div>;
}
+50
View File
@@ -0,0 +1,50 @@
import { useState, type FormEvent } from "react";
import { useAuth } from "../auth";
import { ApiError } from "../api";
export function Login() {
const { login } = useAuth();
const [user, setUser] = useState("");
const [password, setPassword] = useState("");
const [totp, setTotp] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (e: FormEvent): Promise<void> => {
e.preventDefault();
setBusy(true);
setError(null);
try {
await login(user, password, totp);
} catch (err) {
setError(err instanceof ApiError && err.status === 429 ? "Trop de tentatives, patiente." : "Identifiants invalides.");
} finally {
setBusy(false);
}
};
const field = "w-full rounded-md bg-surface-2 border border-border px-3 py-2 text-fg placeholder:text-muted ring-accent";
return (
<div className="min-h-dvh grid place-items-center px-4">
<form onSubmit={submit} className="w-full max-w-sm space-y-4 rounded-xl border border-border bg-surface p-6">
<h1 className="text-2xl font-bold tracking-wide glow text-accent">CHLOVA</h1>
<p className="text-sm text-muted">Accès propriétaire authentification forte.</p>
<input className={field} placeholder="Utilisateur" autoComplete="username" value={user} onChange={(e) => setUser(e.target.value)} />
<input className={field} type="password" placeholder="Mot de passe" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
<input className={field} inputMode="numeric" placeholder="Code 2FA (TOTP)" autoComplete="one-time-code" value={totp} onChange={(e) => setTotp(e.target.value)} />
{error && <p role="alert" className="text-sm text-danger">{error}</p>}
<button
type="submit"
disabled={busy}
className="w-full rounded-md bg-accent px-3 py-2 font-medium text-bg disabled:opacity-50 ring-accent cursor-pointer"
>
{busy ? "Connexion…" : "Se connecter"}
</button>
</form>
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
// Vue Review — remplie en v0.24.0.
export function Review() {
return <div className="p-6 text-muted">Review (v0.24.0)</div>;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noEmit": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"types": ["vite/client", "vite-plugin-pwa/client"]
},
"include": ["src", "vite.config.ts"]
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { VitePWA } from "vite-plugin-pwa";
// Le SPA est servi same-origin par le backend en prod (build → web/dist).
// En dev, proxy /api et /health vers le backend (port 8080).
export default defineConfig({
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: "autoUpdate",
manifest: {
name: "CHLOVA",
short_name: "CHLOVA",
theme_color: "#020617",
background_color: "#020617",
display: "standalone",
icons: [],
},
}),
],
server: {
proxy: {
"/api": { target: "http://localhost:8080", changeOrigin: true },
"/health": { target: "http://localhost:8080", changeOrigin: true },
},
},
build: { outDir: "dist" },
});