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:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user