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:
@@ -6,6 +6,13 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.22.0] — 2026-06-23
|
||||
### Added
|
||||
- Package **`web/`** : scaffold UI (React 19 + Vite 8 + Tailwind 4 + react-router 7,
|
||||
PWA). Tokens dark HUD "Jarvis-red" (`docs/ui-design.md`), client API (`api.ts`),
|
||||
contexte auth JWT (`auth.tsx`), shell + garde de route, écran **Login** (mdp +
|
||||
TOTP). Pages Chat/Review en stubs (v0.23/0.24). `npm run build` OK, 0 vuln.
|
||||
|
||||
## [0.21.0] — 2026-06-23
|
||||
### Added
|
||||
- `orchestrator/scripts/provision-auth.ts` (+ `npm run provision-auth`) : génère
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Design UI CHLOVA
|
||||
|
||||
Style **Dark Mode (OLED) — HUD "Jarvis-red"**. Sombre, contraste élevé, glow
|
||||
minimal sur l'accent. Grounded via le skill UI/UX (dark ops dashboard).
|
||||
|
||||
## Tokens (`web/src/index.css`, `@theme`)
|
||||
| Rôle | Hex | Usage |
|
||||
|---|---|---|
|
||||
| bg | `#020617` | fond |
|
||||
| surface / surface-2 | `#0F172A` / `#1E293B` | cartes, barres |
|
||||
| border | `#334155` | séparateurs |
|
||||
| fg / muted | `#F8FAFC` / `#94A3B8` | texte / secondaire |
|
||||
| **accent** | `#FF3B3B` | identité, action primaire (glow HUD) |
|
||||
| accent-dim / danger | `#B91C1C` | destructif (toujours + icône + confirm) |
|
||||
| success / warning | `#22C55E` / `#F59E0B` | statuts |
|
||||
|
||||
Polices : **Fira Sans** (corps) + **Fira Code** (data/mono).
|
||||
|
||||
## Règles
|
||||
- Accent rouge = identité ET action primaire ; le destructif (`#B91C1C`) est plus
|
||||
sombre **et** accompagné d'icône + confirmation (la couleur n'est pas le seul
|
||||
indicateur — a11y).
|
||||
- Contraste AA min 4.5:1 ; focus visible (`.ring-accent`) ; `prefers-reduced-motion`
|
||||
respecté ; cibles tactiles ≥ 44px.
|
||||
- Pas d'emoji comme icône (SVG/Lucide à venir). Transitions 150–300ms.
|
||||
|
||||
## Stack
|
||||
React 19 + Vite 8 + Tailwind 4 (`@theme`) + react-router 7, PWA (installable).
|
||||
SPA servi same-origin par le backend en prod ; proxy `/api` en dev.
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
Generated
+6732
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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" },
|
||||
});
|
||||
Reference in New Issue
Block a user