feat: UI vue Review + backend sert le SPA, fin Phase 4 v1 (v0.24.0)

Vue Review (liste assets, approuver/refuser + confirm, refresh, 401→logout).
Backend sert le SPA same-origin (@fastify/static + fallback) si CHLOVA_WEB_ROOT.
Dockerfile multi-stage build web+API (contexte racine), image embarque /app/web.
Compose contexte .., image chlova/backend:0.2.0. 65 tests, 0 vuln, compose OK.

Palier de risque : privilégié (surface exposée complète) — non déployée ;
auth + CHLOVA_PHASE requis pour activer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 05:56:01 +02:00
parent aee86b811e
commit e6edf1a8bc
9 changed files with 324 additions and 14 deletions
+95 -2
View File
@@ -1,4 +1,97 @@
// Vue Review — remplie en v0.24.0.
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../auth";
import { api, ApiError, type Asset } from "../api";
export function Review() {
return <div className="p-6 text-muted">Review (v0.24.0)</div>;
const { token, logout } = useAuth();
const [assets, setAssets] = useState<Asset[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const guard = useCallback(
(err: unknown): void => {
if (err instanceof ApiError && err.status === 401) logout();
else setError(err instanceof Error ? err.message : "Erreur");
},
[logout],
);
const refresh = useCallback(async (): Promise<void> => {
if (!token) return;
setLoading(true);
try {
const { assets } = await api.review(token);
setAssets(assets);
setError(null);
} catch (err) {
guard(err);
} finally {
setLoading(false);
}
}, [token, guard]);
useEffect(() => {
void refresh();
}, [refresh]);
const decide = async (id: string, action: "approve" | "refuse"): Promise<void> => {
if (!token) return;
if (action === "refuse" && !confirm(`Refuser définitivement ${id} ?`)) return;
try {
if (action === "approve") await api.approve(token, id);
else await api.refuse(token, id);
await refresh();
} catch (err) {
guard(err);
}
};
const badge = (a: Asset): string =>
a.riskTier === "privileged" ? "text-danger" : "text-success";
return (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Review</h2>
<button onClick={() => void refresh()} className="ml-auto text-sm text-muted hover:text-fg cursor-pointer">
Rafraîchir
</button>
</div>
{error && <p role="alert" className="text-danger text-sm">{error}</p>}
{loading && <p className="text-muted text-sm">Chargement</p>}
{!loading && assets.length === 0 && <p className="text-muted text-sm">Aucun asset en attente.</p>}
<ul className="space-y-2">
{assets.map((a) => (
<li key={a.id} className="rounded-lg border border-border bg-surface p-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm">{a.id}</span>
<span className={`text-xs ${badge(a)}`}>{a.riskTier}</span>
<span className="text-xs text-muted">{a.status} · v{a.version}</span>
{a.expiresAt && (
<span className="text-xs text-warning">
expire {new Date(a.expiresAt).toISOString().slice(0, 10)}
</span>
)}
<div className="ml-auto flex gap-2">
<button
onClick={() => void decide(a.id, "approve")}
className="rounded-md bg-success/20 text-success border border-success/40 px-3 py-1 text-sm cursor-pointer ring-accent"
>
Approuver
</button>
<button
onClick={() => void decide(a.id, "refuse")}
className="rounded-md bg-danger/20 text-danger border border-danger/50 px-3 py-1 text-sm cursor-pointer ring-accent"
>
Refuser
</button>
</div>
</div>
</li>
))}
</ul>
</div>
);
}