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:
@@ -52,6 +52,8 @@ CHLOVA_TOTP_SECRET= # SECRET — secret TOTP (2FA)
|
||||
CHLOVA_JWT_SECRET= # SECRET — clé de signature JWT
|
||||
# Origine CORS autorisée en DEV (Vite). En prod le SPA est servi same-origin.
|
||||
CHLOVA_WEB_ORIGIN= # ex. http://localhost:5173 (dev uniquement)
|
||||
# Racine du SPA buildé servi same-origin. Défaut image = /app/web. Vide = pas de SPA.
|
||||
CHLOVA_WEB_ROOT= # laisser vide en conteneur (défaut /app/web)
|
||||
# Domaine public derrière Traefik (label compose).
|
||||
CHLOVA_DOMAIN=chlova.example.com
|
||||
# Phase 1 : aucun port publié (Telegram en long-polling). Renseigné en P3+ si API/UI.
|
||||
|
||||
@@ -6,6 +6,18 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.24.0] — 2026-06-23 — fin Phase 4 (UI v1 : chat + review)
|
||||
### Added
|
||||
- UI : vue **Review** (`web/src/pages/Review.tsx`) — liste des assets en attente,
|
||||
approuver / refuser (confirmation sur refus, palier coloré), refresh, 401→logout.
|
||||
- Backend sert le **SPA same-origin** (`@fastify/static` 9.1.3 + fallback routes
|
||||
client) si `CHLOVA_WEB_ROOT` présent ; config `webRoot`.
|
||||
### Changed
|
||||
- Dockerfile multi-stage : build **web + API** (contexte = racine dépôt), image
|
||||
embarque le SPA (`/app/web`, `CHLOVA_WEB_ROOT` par défaut). Compose : contexte
|
||||
`..`, `dockerfile: orchestrator/Dockerfile`, image `chlova/backend:0.2.0`.
|
||||
Compose revalidé. 65 tests, 0 vuln.
|
||||
|
||||
## [0.23.0] — 2026-06-23
|
||||
### Added
|
||||
- UI : vue **Chat** (`web/src/pages/Chat.tsx`) — conversation avec l'agent via
|
||||
|
||||
@@ -78,8 +78,9 @@ services:
|
||||
# ── Backend CHLOVA : SEULE surface, cerveau (boucle agent) ──────────────
|
||||
backend:
|
||||
build:
|
||||
context: ../orchestrator # Dockerfile ajouté en Phase 1
|
||||
image: chlova/backend:0.1.0 # tag versionné local
|
||||
context: .. # racine du dépôt (image = API + SPA web)
|
||||
dockerfile: orchestrator/Dockerfile
|
||||
image: chlova/backend:0.2.0 # tag versionné local (API+UI)
|
||||
restart: unless-stopped
|
||||
env_file: ../.env
|
||||
environment:
|
||||
|
||||
+22
-8
@@ -1,21 +1,35 @@
|
||||
# CHLOVA backend — image multi-stage, base épinglée (jamais :latest).
|
||||
# Contexte de build = RACINE du dépôt (voir infra/docker-compose.yml) : l'image
|
||||
# embarque l'API ET le SPA web (servi same-origin).
|
||||
# TODO épingler le digest (node:24.13-bookworm-slim@sha256:...) avant déploiement réel.
|
||||
|
||||
FROM node:24.13-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
# ── Build du SPA (web/) ─────────────────────────────────────────────────
|
||||
FROM node:24.13-bookworm-slim AS web-build
|
||||
WORKDIR /web
|
||||
COPY web/package.json web/package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json tsconfig.build.json ./
|
||||
COPY src ./src
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
# ── Build de l'API (orchestrator/) ──────────────────────────────────────
|
||||
FROM node:24.13-bookworm-slim AS api-build
|
||||
WORKDIR /app
|
||||
COPY orchestrator/package.json orchestrator/package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY orchestrator/tsconfig.json orchestrator/tsconfig.build.json ./
|
||||
COPY orchestrator/src ./src
|
||||
RUN npm run build
|
||||
|
||||
# ── Runtime ─────────────────────────────────────────────────────────────
|
||||
FROM node:24.13-bookworm-slim AS runtime
|
||||
ENV NODE_ENV=production
|
||||
ENV CHLOVA_WEB_ROOT=/app/web
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY orchestrator/package.json orchestrator/package-lock.json* ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=build /app/dist ./dist
|
||||
# Données runtime (SQLite, P2+). L'utilisateur node ne tourne pas en root.
|
||||
COPY --from=api-build /app/dist ./dist
|
||||
COPY --from=web-build /web/dist ./web
|
||||
# Données runtime (SQLite). L'utilisateur node ne tourne pas en root.
|
||||
RUN mkdir -p /app/data && chown -R node:node /app
|
||||
USER node
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||
|
||||
Generated
+165
-2
@@ -8,8 +8,9 @@
|
||||
"name": "chlova-orchestrator",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/rate-limit": "^11.0.0",
|
||||
"@fastify/cors": "11.2.0",
|
||||
"@fastify/rate-limit": "11.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"fastify": "5.8.5",
|
||||
"jose": "6.2.3",
|
||||
@@ -503,6 +504,22 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/accept-negotiator": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
|
||||
"integrity": "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/ajv-compiler": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
|
||||
@@ -655,6 +672,53 @@
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/send": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.0.tgz",
|
||||
"integrity": "sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"fast-decode-uri-component": "^1.0.1",
|
||||
"http-errors": "^2.0.0",
|
||||
"mime": "^3"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/static": {
|
||||
"version": "9.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz",
|
||||
"integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/accept-negotiator": "^2.0.0",
|
||||
"@fastify/send": "^4.0.0",
|
||||
"content-disposition": "^1.0.1",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"fastq": "^1.17.1",
|
||||
"glob": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.14",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||
@@ -1392,6 +1456,15 @@
|
||||
"fastq": "^1.17.1"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
@@ -1429,6 +1502,18 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -2076,6 +2161,23 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.6",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.2.2",
|
||||
"minipass": "^7.1.3",
|
||||
"path-scurry": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -2556,6 +2658,15 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.5.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
|
||||
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -2596,6 +2707,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
|
||||
"integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
@@ -2621,6 +2744,30 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -2752,6 +2899,22 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
|
||||
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/cors": "11.2.0",
|
||||
"@fastify/rate-limit": "11.0.0",
|
||||
"@fastify/static": "9.1.3",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"fastify": "5.8.5",
|
||||
"jose": "6.2.3",
|
||||
|
||||
@@ -78,6 +78,8 @@ const schema = z.object({
|
||||
(v) => (typeof v === "string" && v.length > 0 ? v : undefined),
|
||||
z.string().url().optional(),
|
||||
), // origine CORS autorisée (dev) ; en prod le SPA est servi en same-origin
|
||||
// Racine du SPA buildé, servi same-origin (vide = ne sert pas de SPA).
|
||||
webRoot: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof schema>;
|
||||
@@ -116,6 +118,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
totpSecret: env.CHLOVA_TOTP_SECRET,
|
||||
jwtSecret: env.CHLOVA_JWT_SECRET,
|
||||
webOrigin: env.CHLOVA_WEB_ORIGIN,
|
||||
webRoot: env.CHLOVA_WEB_ROOT,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import Fastify, { type FastifyBaseLogger } from "fastify";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { loadConfig, assertReadOnlyPhase, redactedConfig, apiAuth } from "./config.js";
|
||||
import { registerApi } from "./api/routes.js";
|
||||
import { createLogger } from "./audit/log.js";
|
||||
@@ -128,6 +131,24 @@ async function main(): Promise<void> {
|
||||
logger.info("API/UI désactivée (auth non configurée) — surface Telegram seule");
|
||||
}
|
||||
|
||||
// SPA same-origin : sert le build web + fallback (routes client) si présent.
|
||||
if (auth && cfg.webRoot) {
|
||||
const root = resolve(cfg.webRoot);
|
||||
if (existsSync(root)) {
|
||||
await app.register(fastifyStatic, { root });
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
// /api et /health restent gérés ; le reste retombe sur l'app SPA.
|
||||
if (req.url.startsWith("/api") || req.url.startsWith("/health")) {
|
||||
return reply.code(404).send({ error: "not found" });
|
||||
}
|
||||
return reply.sendFile("index.html");
|
||||
});
|
||||
logger.info({ root }, "SPA servi same-origin");
|
||||
} else {
|
||||
logger.warn({ root }, "CHLOVA_WEB_ROOT introuvable — SPA non servi");
|
||||
}
|
||||
}
|
||||
|
||||
await app.listen({ host: "0.0.0.0", port: 8080 });
|
||||
logger.info({ port: 8080 }, "healthcheck interne prêt");
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user