diff --git a/CLAUDE.md b/CLAUDE.md index 87583ce..a2e374a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ LLM Provider Adapters (OpenAI, Anthropic, Azure, Mistral, Ollama) - Prometheus — metrics scraper on :9090; Grafana — dashboards on :3001 (admin/admin) - HashiCorp Vault — secrets and API key rotation (90-day cycle) -**Frontend:** React 18 + TypeScript + Vite, shadcn/ui, recharts. Routes protected via local JWT (stored in localStorage, auto-logout on expiry); `web/src/auth/` manages the auth flow. API clients live in `web/src/api/`. +**Frontend:** React 18 + TypeScript + Vite, shadcn/ui, recharts. Routes protected via local JWT (stored in localStorage, auto-logout on expiry); `web/src/auth/` manages the auth flow. API clients live in `web/src/api/` and use React Query. **The UI is in French** — use French labels/copy in all dashboard pages; `date-fns` with `fr` locale for date formatting. **Documentation site** (`http://localhost:3000/docs`): public, no auth required. Root: `web/src/pages/docs/` — sections: getting-started, installation, api-reference (8 endpoints), guides (6), deployment (3), security (2), changelog. Layout components: `DocLayout.tsx` (sidebar + content + TOC), `DocSidebar.tsx` (with search), `DocBreadcrumbs.tsx`, `DocPagination.tsx`. Shared components: `components/CodeBlock.tsx`, `Callout.tsx`, `ApiEndpoint.tsx`, `ParamTable.tsx`, `TableOfContents.tsx`. Nav structure: `web/src/pages/docs/nav.ts`. Uses `@tailwindcss/typography` (added as devDependency) for prose rendering. @@ -84,7 +84,7 @@ config.yaml # Local dev config (overridden by VEYLANT_* env vars) Use `make` as the primary interface. The proxy runs on **:8090**, PII HTTP on **:8091**, PII gRPC on **:50051**. ```bash -make dev # Start full stack (proxy + PostgreSQL + ClickHouse + Redis + Keycloak + PII) +make dev # Start full stack (proxy + PostgreSQL + ClickHouse + Redis + PII) make dev-down # Stop and remove all containers and volumes make dev-logs # Tail logs from all services make build # go build → bin/proxy @@ -136,6 +136,8 @@ pytest services/pii/tests/test_file.py::test_function **Provider configs:** LLM provider API keys are stored encrypted (AES-256-GCM) in the `provider_configs` table (migration 000011). CRUD via `GET|POST /v1/admin/providers`, `PUT|DELETE|POST-test /v1/admin/providers/{id}`. Adapters hot-reload on save/update without proxy restart (`router.UpdateAdapter()` / `RemoveAdapter()`). +**`admin.Handler` builder pattern:** The handler is wired via method chaining — `adminHandler.WithDB(db).WithRouter(providerRouter).WithRateLimiter(rateLimiter).WithFlagStore(flagStore).WithEncryptor(encryptor)`. All `With*` methods are optional; missing dependencies disable their feature. + **Tools required:** `buf` (`brew install buf`), `golang-migrate` (`brew install golang-migrate`), `golangci-lint`, Python 3.12, `black`, `ruff`. **Tenant onboarding** (after `make dev`): @@ -148,7 +150,7 @@ deploy/onboarding/import-users.sh # bulk import from CSV (email, first_name When `server.env=development`, the proxy degrades gracefully instead of crashing: - **PostgreSQL unreachable** → routing engine and feature flags disabled; flag store uses in-memory fallback -- **ClickHouse unreachable** → audit logging disabled +- **ClickHouse unreachable** → audit logging falls back to `auditlog.MemLogger` (in-memory, not persisted across restarts) - **PII service unreachable** → PII disabled if `pii.fail_open=true` (default) In production (`server.env=production`), any of the above causes a fatal startup error. diff --git a/cmd/proxy/main.go b/cmd/proxy/main.go index e3b979c..3d468d2 100644 --- a/cmd/proxy/main.go +++ b/cmd/proxy/main.go @@ -30,6 +30,7 @@ import ( "github.com/veylant/ia-gateway/internal/health" "github.com/veylant/ia-gateway/internal/metrics" "github.com/veylant/ia-gateway/internal/middleware" + "github.com/veylant/ia-gateway/internal/notifications" "github.com/veylant/ia-gateway/internal/pii" "github.com/veylant/ia-gateway/internal/provider" "github.com/veylant/ia-gateway/internal/provider/anthropic" @@ -225,6 +226,13 @@ func main() { logger.Warn("clickhouse.dsn not set — audit logging disabled") } + // In development, fall back to in-memory audit logger so the admin API + // returns data instead of 501 when ClickHouse is not running. + if auditLogger == nil && cfg.Server.Env == "development" { + auditLogger = auditlog.NewMemLogger() + logger.Warn("audit logging: using in-memory fallback — data not persisted across restarts") + } + // ── Feature flag store (E4-12 zero-retention + future flags + E11-07) ────── var flagStore flags.FlagStore if db != nil { @@ -237,6 +245,31 @@ func main() { // Wire flag store into the provider router so it can check routing_enabled (E11-07). providerRouter.WithFlagStore(flagStore) + // ── Email notifications (Mailtrap / any SMTP relay) ─────────────────────── + var notifHandler *notifications.Handler + if cfg.Notifications.SMTP.Host != "" && cfg.Notifications.SMTP.Username != "" { + mailer, mailerErr := notifications.New(notifications.Config{ + Host: cfg.Notifications.SMTP.Host, + Port: cfg.Notifications.SMTP.Port, + Username: cfg.Notifications.SMTP.Username, + Password: cfg.Notifications.SMTP.Password, + From: cfg.Notifications.SMTP.From, + FromName: cfg.Notifications.SMTP.FromName, + }) + if mailerErr != nil { + logger.Warn("email notifications disabled — invalid SMTP config", zap.Error(mailerErr)) + } else { + notifHandler = notifications.NewHandler(mailer, logger) + logger.Info("email notifications enabled", + zap.String("smtp_host", cfg.Notifications.SMTP.Host), + zap.Int("smtp_port", cfg.Notifications.SMTP.Port), + zap.String("from", cfg.Notifications.SMTP.From), + ) + } + } else { + logger.Warn("email notifications disabled — notifications.smtp.host or username not set") + } + // ── Proxy handler ───────────────────────────────────────────────────────── proxyHandler := proxy.NewWithAudit(providerRouter, logger, piiClient, auditLogger, encryptor). WithFlagStore(flagStore) @@ -308,6 +341,11 @@ func main() { r.Post("/chat/completions", proxyHandler.ServeHTTP) + // Email notification delivery — called by the frontend when a budget threshold is crossed. + if notifHandler != nil { + r.Post("/notifications/send", notifHandler.ServeHTTP) + } + // PII analyze endpoint for Playground (E8-11, Sprint 8). piiAnalyzeHandler := pii.NewAnalyzeHandler(piiClient, logger) r.Post("/pii/analyze", piiAnalyzeHandler.ServeHTTP) diff --git a/config.yaml b/config.yaml index 82a6c5c..e564ecd 100644 --- a/config.yaml +++ b/config.yaml @@ -114,3 +114,16 @@ rate_limit: default_tenant_burst: 200 default_user_rpm: 100 default_user_burst: 20 + +# Email notifications via SMTP (Mailtrap sandbox). +# Override password in production: VEYLANT_NOTIFICATIONS_SMTP_PASSWORD= +# Full password visible in Mailtrap dashboard → Sending → SMTP Settings → Show credentials. +notifications: + smtp: + host: "sandbox.smtp.mailtrap.io" + port: 587 + username: "2597bd31d265eb" + # Mailtrap password — replace ****3c89 with the full value from the dashboard. + password: "cd126234193c89" + from: "noreply@veylant.ai" + from_name: "Veylant IA" diff --git a/internal/auditlog/entry.go b/internal/auditlog/entry.go index 697f986..0dc9ac9 100644 --- a/internal/auditlog/entry.go +++ b/internal/auditlog/entry.go @@ -9,28 +9,28 @@ import "time" // prompt_anonymized is stored encrypted (AES-256-GCM) and is never // returned to API callers. type AuditEntry struct { - RequestID string - TenantID string - UserID string - Timestamp time.Time - ModelRequested string - ModelUsed string - Provider string - Department string - UserRole string - PromptHash string // hex SHA-256 of the original (pre-PII) prompt - ResponseHash string // hex SHA-256 of the response content - PromptAnonymized string // AES-256-GCM base64-encoded anonymized prompt - SensitivityLevel string // "none"|"low"|"medium"|"high"|"critical" - TokenInput int - TokenOutput int - TokenTotal int - CostUSD float64 - LatencyMs int - Status string // "ok"|"error" - ErrorType string - PIIEntityCount int - Stream bool + RequestID string `json:"request_id"` + TenantID string `json:"tenant_id"` + UserID string `json:"user_id"` + Timestamp time.Time `json:"timestamp"` + ModelRequested string `json:"model_requested"` + ModelUsed string `json:"model_used"` + Provider string `json:"provider"` + Department string `json:"department"` + UserRole string `json:"user_role"` + PromptHash string `json:"prompt_hash"` + ResponseHash string `json:"response_hash"` + PromptAnonymized string `json:"-"` // AES-256-GCM base64-encoded anonymized prompt — never returned to API callers + SensitivityLevel string `json:"sensitivity_level"` // "none"|"low"|"medium"|"high"|"critical" + TokenInput int `json:"token_input"` + TokenOutput int `json:"token_output"` + TokenTotal int `json:"token_total"` + CostUSD float64 `json:"cost_usd"` + LatencyMs int `json:"latency_ms"` + Status string `json:"status"` // "ok"|"error" + ErrorType string `json:"error_type"` + PIIEntityCount int `json:"pii_entity_count"` + Stream bool `json:"stream"` } // AuditQuery filters audit log entries for the GET /v1/admin/logs endpoint. @@ -47,8 +47,8 @@ type AuditQuery struct { // AuditResult is the paginated response for AuditQuery. type AuditResult struct { - Data []AuditEntry - Total int + Data []AuditEntry `json:"data"` + Total int `json:"total"` } // CostQuery filters cost aggregation for the GET /v1/admin/costs endpoint. @@ -61,13 +61,13 @@ type CostQuery struct { // CostSummary is one row in a cost aggregation result. type CostSummary struct { - Key string - TotalTokens int - TotalCostUSD float64 - RequestCount int + Key string `json:"key"` + TotalTokens int `json:"total_tokens"` + TotalCostUSD float64 `json:"total_cost_usd"` + RequestCount int `json:"request_count"` } // CostResult is the response for CostQuery. type CostResult struct { - Data []CostSummary + Data []CostSummary `json:"data"` } diff --git a/internal/auditlog/logger.go b/internal/auditlog/logger.go index 948c8f1..22072f9 100644 --- a/internal/auditlog/logger.go +++ b/internal/auditlog/logger.go @@ -52,7 +52,7 @@ func (m *MemLogger) Query(_ context.Context, q AuditQuery) (*AuditResult, error) } minLevel := sensitivityOrder[q.MinSensitivity] - var filtered []AuditEntry + filtered := make([]AuditEntry, 0) for _, e := range m.entries { if e.TenantID != q.TenantID { continue @@ -81,7 +81,7 @@ func (m *MemLogger) Query(_ context.Context, q AuditQuery) (*AuditResult, error) if q.Offset < len(filtered) { filtered = filtered[q.Offset:] } else { - filtered = nil + filtered = make([]AuditEntry, 0) } limit := q.Limit if limit <= 0 || limit > 200 { @@ -133,7 +133,7 @@ func (m *MemLogger) QueryCosts(_ context.Context, q CostQuery) (*CostResult, err totals[key].count++ } - var data []CostSummary + data := make([]CostSummary, 0) for k, v := range totals { data = append(data, CostSummary{ Key: k, diff --git a/internal/config/config.go b/internal/config/config.go index 7283eb9..2d065ab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,19 +11,38 @@ import ( // Values are loaded from config.yaml then overridden by env vars prefixed with VEYLANT_. // Example: VEYLANT_SERVER_PORT=9090 overrides server.port. type Config struct { - Server ServerConfig `mapstructure:"server"` - Database DatabaseConfig `mapstructure:"database"` - Redis RedisConfig `mapstructure:"redis"` - Auth AuthConfig `mapstructure:"auth"` - PII PIIConfig `mapstructure:"pii"` - Log LogConfig `mapstructure:"log"` - Providers ProvidersConfig `mapstructure:"providers"` - RBAC RBACConfig `mapstructure:"rbac"` - Metrics MetricsConfig `mapstructure:"metrics"` - Routing RoutingConfig `mapstructure:"routing"` - ClickHouse ClickHouseConfig `mapstructure:"clickhouse"` - Crypto CryptoConfig `mapstructure:"crypto"` - RateLimit RateLimitConfig `mapstructure:"rate_limit"` + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + Redis RedisConfig `mapstructure:"redis"` + Auth AuthConfig `mapstructure:"auth"` + PII PIIConfig `mapstructure:"pii"` + Log LogConfig `mapstructure:"log"` + Providers ProvidersConfig `mapstructure:"providers"` + RBAC RBACConfig `mapstructure:"rbac"` + Metrics MetricsConfig `mapstructure:"metrics"` + Routing RoutingConfig `mapstructure:"routing"` + ClickHouse ClickHouseConfig `mapstructure:"clickhouse"` + Crypto CryptoConfig `mapstructure:"crypto"` + RateLimit RateLimitConfig `mapstructure:"rate_limit"` + Notifications NotificationsConfig `mapstructure:"notifications"` +} + +// NotificationsConfig holds outbound notification settings. +type NotificationsConfig struct { + SMTP SMTPConfig `mapstructure:"smtp"` +} + +// SMTPConfig holds SMTP relay settings for email notifications. +// Sensitive fields (Password) should be provided via env vars: +// +// VEYLANT_NOTIFICATIONS_SMTP_PASSWORD= +type SMTPConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` + From string `mapstructure:"from"` + FromName string `mapstructure:"from_name"` } // RateLimitConfig holds default rate limiting parameters applied to all tenants @@ -222,6 +241,9 @@ func Load() (*Config, error) { v.SetDefault("rate_limit.default_tenant_burst", 200) v.SetDefault("rate_limit.default_user_rpm", 100) v.SetDefault("rate_limit.default_user_burst", 20) + v.SetDefault("notifications.smtp.port", 587) + v.SetDefault("notifications.smtp.from", "noreply@veylant.ai") + v.SetDefault("notifications.smtp.from_name", "Veylant IA") if err := v.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); !ok { diff --git a/internal/notifications/handler.go b/internal/notifications/handler.go new file mode 100644 index 0000000..0c8f380 --- /dev/null +++ b/internal/notifications/handler.go @@ -0,0 +1,176 @@ +package notifications + +import ( + "encoding/json" + "fmt" + "net/http" + + "go.uber.org/zap" + + "github.com/veylant/ia-gateway/internal/apierror" + "github.com/veylant/ia-gateway/internal/middleware" +) + +// Handler handles POST /v1/notifications/send — sends a budget alert email. +// Requires a valid JWT (caller must be authenticated). +type Handler struct { + mailer *Mailer + logger *zap.Logger +} + +// NewHandler creates a Handler. mailer must not be nil. +func NewHandler(mailer *Mailer, logger *zap.Logger) *Handler { + return &Handler{mailer: mailer, logger: logger} +} + +// SendRequest is the JSON body for POST /v1/notifications/send. +type SendRequest struct { + To string `json:"to"` // recipient email address + AlertName string `json:"alert_name"` // human-readable rule name + Scope string `json:"scope"` // "global" | provider name + Spend float64 `json:"spend"` // current spend for the scope + Threshold float64 `json:"threshold"` // configured threshold + Type string `json:"type"` // "budget_exceeded" | "budget_warning" +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + reqID := middleware.RequestIDFromContext(r.Context()) + + var req SendRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + apierror.WriteErrorWithRequestID(w, &apierror.APIError{ + Type: "invalid_request_error", Message: "Invalid JSON body", + Code: "invalid_request", HTTPStatus: http.StatusBadRequest, + }, reqID) + return + } + if req.To == "" { + apierror.WriteErrorWithRequestID(w, &apierror.APIError{ + Type: "invalid_request_error", Message: "Field 'to' is required", + Code: "invalid_request", HTTPStatus: http.StatusBadRequest, + }, reqID) + return + } + if req.AlertName == "" { + apierror.WriteErrorWithRequestID(w, &apierror.APIError{ + Type: "invalid_request_error", Message: "Field 'alert_name' is required", + Code: "invalid_request", HTTPStatus: http.StatusBadRequest, + }, reqID) + return + } + + subject, html := buildEmail(req) + if err := h.mailer.Send(Message{To: req.To, Subject: subject, HTML: html}); err != nil { + h.logger.Warn("notification email send failed", + zap.Error(err), + zap.String("to", req.To), + zap.String("alert", req.AlertName), + zap.String("request_id", reqID), + ) + apierror.WriteErrorWithRequestID(w, &apierror.APIError{ + Type: "api_error", + Message: "Failed to send notification email — check SMTP configuration", + Code: "email_send_failed", + HTTPStatus: http.StatusServiceUnavailable, + }, reqID) + return + } + + h.logger.Info("notification email sent", + zap.String("to", req.To), + zap.String("alert", req.AlertName), + zap.String("type", req.Type), + ) + w.WriteHeader(http.StatusNoContent) +} + +// buildEmail returns the subject and HTML body for a budget alert notification. +func buildEmail(req SendRequest) (subject, html string) { + isExceeded := req.Type == "budget_exceeded" + + scopeLabel := req.Scope + if req.Scope == "global" { + scopeLabel = "Tous fournisseurs" + } + + pct := 0.0 + if req.Threshold > 0 { + pct = (req.Spend / req.Threshold) * 100 + } + + accentColor := "#f59e0b" // warning amber + statusLabel := "Alerte 80 % — seuil bientôt atteint" + emoji := "⚠️" + if isExceeded { + accentColor = "#ef4444" // destructive red + statusLabel = "Seuil dépassé" + emoji = "🚨" + } + + subject = fmt.Sprintf("[Veylant IA] %s %s : %s", emoji, statusLabel, req.AlertName) + + html = fmt.Sprintf(` + + + +
+ + +
+

Veylant IA · Governance Hub

+

%s

+
+ + +
+

Règle : %s

+ + + + + + + + + + + + + + + + + + +
Périmètre%s
Dépenses actuelles$%.6f
Seuil configuré$%.6f
Consommation%.1f %%
+ + +
+
+
+

%.1f %% du seuil

+
+ + +
+

+ Cet email a été envoyé automatiquement par Veylant IA.
+ Gérez vos règles d'alerte dans Paramètres → Notifications. +

+
+ +
+ +`, + accentColor, statusLabel, + req.AlertName, + scopeLabel, + accentColor, req.Spend, + req.Threshold, + accentColor, pct, + accentColor, pct, + pct, + ) + + return subject, html +} diff --git a/internal/notifications/mailer.go b/internal/notifications/mailer.go new file mode 100644 index 0000000..5eb7f49 --- /dev/null +++ b/internal/notifications/mailer.go @@ -0,0 +1,102 @@ +// Package notifications provides SMTP email delivery for budget alert notifications. +package notifications + +import ( + "crypto/tls" + "fmt" + "net" + "net/smtp" + "strings" +) + +// Config holds SMTP relay configuration. +type Config struct { + Host string + Port int + Username string + Password string + From string + FromName string +} + +// Mailer sends transactional emails via SMTP (STARTTLS, PLAIN auth). +// Tested against Mailtrap sandbox; compatible with any RFC 4954 relay. +type Mailer struct { + cfg Config +} + +// New creates a Mailer. Returns an error if the config is obviously invalid. +func New(cfg Config) (*Mailer, error) { + if cfg.Host == "" { + return nil, fmt.Errorf("smtp host is required") + } + if cfg.Port == 0 { + cfg.Port = 587 + } + return &Mailer{cfg: cfg}, nil +} + +// Message is a single outbound email. +type Message struct { + To string // single recipient address + Subject string + HTML string // HTML body +} + +// Send delivers msg via SMTP with STARTTLS + PLAIN auth. +func (m *Mailer) Send(msg Message) error { + from := m.cfg.From + if m.cfg.FromName != "" { + from = fmt.Sprintf("%s <%s>", m.cfg.FromName, m.cfg.From) + } + + headers := strings.Join([]string{ + fmt.Sprintf("From: %s", from), + fmt.Sprintf("To: %s", msg.To), + fmt.Sprintf("Subject: %s", msg.Subject), + "MIME-Version: 1.0", + `Content-Type: text/html; charset="UTF-8"`, + "", + "", + }, "\r\n") + body := []byte(headers + msg.HTML) + + addr := fmt.Sprintf("%s:%d", m.cfg.Host, m.cfg.Port) + conn, err := net.Dial("tcp", addr) + if err != nil { + return fmt.Errorf("smtp dial %s: %w", addr, err) + } + + client, err := smtp.NewClient(conn, m.cfg.Host) + if err != nil { + return fmt.Errorf("smtp client: %w", err) + } + defer client.Close() //nolint:errcheck + + // Upgrade to TLS via STARTTLS if the server supports it (all Mailtrap ports do). + if ok, _ := client.Extension("STARTTLS"); ok { + if err = client.StartTLS(&tls.Config{ServerName: m.cfg.Host}); err != nil { + return fmt.Errorf("starttls: %w", err) + } + } + + auth := smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host) + if err = client.Auth(auth); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + if err = client.Mail(m.cfg.From); err != nil { + return fmt.Errorf("smtp MAIL FROM: %w", err) + } + if err = client.Rcpt(msg.To); err != nil { + return fmt.Errorf("smtp RCPT TO: %w", err) + } + + wc, err := client.Data() + if err != nil { + return fmt.Errorf("smtp DATA: %w", err) + } + if _, err = wc.Write(body); err != nil { + return fmt.Errorf("smtp write body: %w", err) + } + return wc.Close() +} diff --git a/proxy b/proxy index 24ccb2b..b27cbf5 100755 Binary files a/proxy and b/proxy differ diff --git a/web/src/components/AppLayout.tsx b/web/src/components/AppLayout.tsx index 90483c4..ca73dcd 100644 --- a/web/src/components/AppLayout.tsx +++ b/web/src/components/AppLayout.tsx @@ -1,6 +1,7 @@ import { Outlet } from "react-router-dom"; import { Sidebar } from "./Sidebar"; import { Header } from "./Header"; +import { NotificationWatcher } from "./NotificationWatcher"; import { useRegisterTokenAccessor } from "@/auth/AuthProvider"; export function AppLayout() { @@ -12,6 +13,8 @@ export function AppLayout() {
+ {/* Invisible watcher: polls costs every 30 s and generates notifications */} +
diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index e3d2792..8523cce 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -1,70 +1,34 @@ -import { useState, useEffect } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; -import { LogOut, User, Bell } from "lucide-react"; +import { useLocation } from "react-router-dom"; +import { LogOut, User } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useAuth } from "@/auth/AuthProvider"; -import { useCosts } from "@/api/costs"; +import { NotificationCenter } from "./NotificationCenter"; const breadcrumbMap: Record = { - "/": "Vue d'ensemble", - "/policies": "Politiques de routage", - "/users": "Utilisateurs", - "/providers": "Fournisseurs IA", - "/alerts": "Alertes Budget", - "/logs": "Journaux d'audit", - "/settings": "Paramètres", - "/playground": "Playground IA", - "/security": "Sécurité RSSI", - "/costs": "Coûts IA", - "/compliance": "Conformité RGPD / AI Act", + "/dashboard": "Vue d'ensemble", + "/dashboard/playground": "Playground IA", + "/dashboard/policies": "Politiques de routage", + "/dashboard/users": "Utilisateurs", + "/dashboard/providers": "Fournisseurs IA", + "/dashboard/security": "Sécurité RSSI", + "/dashboard/costs": "Coûts IA", + "/dashboard/alerts": "Alertes Budget", + "/dashboard/logs": "Journaux d'audit", + "/dashboard/compliance": "Conformité RGPD / AI Act", + "/dashboard/settings": "Paramètres", }; -const STORAGE_KEY = "veylant_budget_alerts"; - -interface BudgetAlert { - id: string; - threshold: number; -} - -function loadAlerts(): BudgetAlert[] { - try { - return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]"); - } catch { - return []; - } -} - export function Header() { const location = useLocation(); - const navigate = useNavigate(); const { user, logout } = useAuth(); const title = breadcrumbMap[location.pathname] ?? "Dashboard"; - const [alerts] = useState(loadAlerts); - const { data: costData } = useCosts({}, 60_000); - const totalCost = costData?.data.reduce((s, c) => s + c.total_cost_usd, 0) ?? 0; - const triggeredCount = alerts.filter((a) => totalCost >= a.threshold).length; - return (

{title}

- {/* Bell badge — E8-13 */} - +
@@ -75,6 +39,7 @@ export function Header() { )}
+ diff --git a/web/src/components/NotificationCenter.tsx b/web/src/components/NotificationCenter.tsx new file mode 100644 index 0000000..2319727 --- /dev/null +++ b/web/src/components/NotificationCenter.tsx @@ -0,0 +1,272 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { Bell, Check, CheckCheck, Trash2, TriangleAlert, TrendingUp, Mail } from "lucide-react"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { + loadNotifications, + saveNotifications, + NOTIF_CHANGE_EVENT, +} from "@/lib/notifications"; +import type { AppNotification } from "@/lib/notifications"; + +const NOTIF_EMAIL_KEY = "veylant_notif_email"; + +function getNotifEmail(): string { + return localStorage.getItem(NOTIF_EMAIL_KEY) ?? ""; +} + +export function NotificationCenter() { + const [notifications, setNotifications] = useState([]); + const [open, setOpen] = useState(false); + const panelRef = useRef(null); + const navigate = useNavigate(); + + const refresh = useCallback(() => { + setNotifications(loadNotifications()); + }, []); + + // Keep in sync with saves from NotificationWatcher and other tabs + useEffect(() => { + refresh(); + window.addEventListener(NOTIF_CHANGE_EVENT, refresh); + window.addEventListener("storage", refresh); + return () => { + window.removeEventListener(NOTIF_CHANGE_EVENT, refresh); + window.removeEventListener("storage", refresh); + }; + }, [refresh]); + + // Close on outside click + useEffect(() => { + if (!open) return; + function onMouseDown(e: MouseEvent) { + if (panelRef.current && !panelRef.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", onMouseDown); + return () => document.removeEventListener("mousedown", onMouseDown); + }, [open]); + + const unreadCount = notifications.filter((n) => !n.readAt).length; + const readCount = notifications.filter((n) => !!n.readAt).length; + const emailConfigured = !!getNotifEmail(); + + const markRead = (id: string) => { + const updated = notifications.map((n) => + n.id === id ? { ...n, readAt: new Date().toISOString() } : n + ); + setNotifications(updated); + saveNotifications(updated); + }; + + const markAllRead = () => { + const now = new Date().toISOString(); + const updated = notifications.map((n) => ({ ...n, readAt: n.readAt ?? now })); + setNotifications(updated); + saveNotifications(updated); + }; + + const clearRead = () => { + const updated = notifications.filter((n) => !n.readAt); + setNotifications(updated); + saveNotifications(updated); + }; + + return ( +
+ {/* Bell button */} + + + {/* ── Notification panel ─────────────────────────────────────────────── */} + {open && ( +
+ {/* Header */} +
+
+ + Notifications + {unreadCount > 0 && ( + + {unreadCount} + + )} + {emailConfigured && ( + + )} +
+
+ {unreadCount > 0 && ( + + )} + {readCount > 0 && ( + + )} +
+
+ + {/* Email indicator */} + {emailConfigured && ( +
+ + + Copies email vers {getNotifEmail()} + +
+ )} + + {/* List */} +
+ {notifications.length === 0 ? ( +
+ +

+ Aucune notification +

+

+ Configurez des alertes budget pour être notifié automatiquement +

+ +
+ ) : ( +
    + {notifications.map((n, idx) => { + const isUnread = !n.readAt; + return ( +
  • + {idx > 0 && } +
    + {/* Type icon */} +
    + {n.type === "budget_exceeded" ? ( + + ) : ( + + )} +
    + + {/* Body */} +
    +
    +

    + {n.title} +

    + {isUnread && ( +
    + )} +
    +

    + {n.message} +

    +
    +

    + {format(new Date(n.createdAt), "dd MMM à HH:mm", { locale: fr })} +

    + {emailConfigured && ( + + + envoyé + + )} +
    +
    + + {/* Mark as read */} + {isUnread && ( + + )} +
    +
  • + ); + })} +
+ )} +
+ + {/* Footer */} + {notifications.length > 0 && ( + <> + +
+ +
+ + )} +
+ )} +
+ ); +} diff --git a/web/src/components/NotificationWatcher.tsx b/web/src/components/NotificationWatcher.tsx new file mode 100644 index 0000000..d3bfbbb --- /dev/null +++ b/web/src/components/NotificationWatcher.tsx @@ -0,0 +1,138 @@ +/** + * NotificationWatcher — invisible component mounted in AppLayout. + * + * Watches the live cost data (refreshed every 30 s) and generates + * AppNotifications when a budget alert transitions: + * - below threshold → above threshold (budget_exceeded) + * - below 80 % → ≥ 80 % (budget_warning) + * + * Uses refs to track previous state so it fires only on the crossing, + * not on every subsequent refresh. + * + * When an email is configured in localStorage (veylant_notif_email), + * it also calls POST /v1/notifications/send for each new notification. + */ +import { useEffect, useRef } from "react"; +import { useCosts } from "@/api/costs"; +import { apiFetch } from "@/api/client"; +import { loadNotifications, saveNotifications } from "@/lib/notifications"; +import type { AppNotification } from "@/lib/notifications"; + +const ALERTS_KEY = "veylant_budget_alerts"; +const NOTIF_EMAIL_KEY = "veylant_notif_email"; + +interface BudgetAlert { + id: string; + name: string; + scope: string; + threshold: number; +} + +function loadAlerts(): BudgetAlert[] { + try { + return JSON.parse(localStorage.getItem(ALERTS_KEY) ?? "[]"); + } catch { + return []; + } +} + +async function sendEmailNotification(notif: AppNotification, to: string): Promise { + await apiFetch("/v1/notifications/send", { + method: "POST", + body: JSON.stringify({ + to, + alert_name: notif.title, + scope: notif.scope, + spend: notif.spend, + threshold: notif.threshold, + type: notif.type, + }), + }); +} + +export function NotificationWatcher() { + const { data: costData } = useCosts({ group_by: "provider" }, 30_000); + + const prevExceeded = useRef>(new Set()); + const prevWarning = useRef>(new Set()); + + useEffect(() => { + if (!costData) return; + + const rows = costData.data ?? []; + const costByProvider = Object.fromEntries(rows.map((c) => [c.key, c.total_cost_usd])); + const totalCost = rows.reduce((s, c) => s + c.total_cost_usd, 0); + + function getSpend(scope: string): number { + return scope === "global" ? totalCost : (costByProvider[scope] ?? 0); + } + + const alerts = loadAlerts(); + const existing = loadNotifications(); + const newNotifs: AppNotification[] = []; + + for (const alert of alerts) { + const spend = getSpend(alert.scope); + const pct = alert.threshold > 0 ? (spend / alert.threshold) * 100 : 0; + const isExceeded = spend >= alert.threshold; + const isWarning = !isExceeded && pct >= 80; + const scopeLabel = alert.scope === "global" ? "Dépenses totales" : alert.scope; + + // ── Exceeded ─────────────────────────────────────────────────────────── + if (isExceeded && !prevExceeded.current.has(alert.id)) { + newNotifs.push({ + id: crypto.randomUUID(), + type: "budget_exceeded", + title: `Seuil dépassé : ${alert.name}`, + message: `${scopeLabel} : $${spend.toFixed(6)} — seuil $${alert.threshold.toFixed(6)}`, + alertId: alert.id, + scope: alert.scope, + spend, + threshold: alert.threshold, + createdAt: new Date().toISOString(), + readAt: null, + }); + prevExceeded.current.add(alert.id); + prevWarning.current.delete(alert.id); // promote from warning + } else if (!isExceeded) { + prevExceeded.current.delete(alert.id); + } + + // ── Warning (80 %) ───────────────────────────────────────────────────── + if (isWarning && !prevWarning.current.has(alert.id)) { + newNotifs.push({ + id: crypto.randomUUID(), + type: "budget_warning", + title: `Attention : ${alert.name} à ${pct.toFixed(0)} %`, + message: `${scopeLabel} : $${spend.toFixed(6)} (${pct.toFixed(1)} % du seuil)`, + alertId: alert.id, + scope: alert.scope, + spend, + threshold: alert.threshold, + createdAt: new Date().toISOString(), + readAt: null, + }); + prevWarning.current.add(alert.id); + } else if (!isWarning) { + prevWarning.current.delete(alert.id); + } + } + + if (newNotifs.length === 0) return; + + // Persist new notifications + saveNotifications([...newNotifs, ...existing]); + + // Fire-and-forget email delivery if an address is configured + const email = localStorage.getItem(NOTIF_EMAIL_KEY); + if (email) { + for (const n of newNotifs) { + sendEmailNotification(n, email).catch(() => { + // Non-blocking: email failure should never break the UI + }); + } + } + }, [costData]); + + return null; +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 79e68d6..5cc70e2 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -71,7 +71,7 @@ export function Sidebar() { {/* Bottom nav */}
cn( "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors", diff --git a/web/src/lib/notifications.ts b/web/src/lib/notifications.ts new file mode 100644 index 0000000..07beff7 --- /dev/null +++ b/web/src/lib/notifications.ts @@ -0,0 +1,42 @@ +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface AppNotification { + id: string; + type: "budget_exceeded" | "budget_warning"; + title: string; + message: string; + alertId: string; + /** Scope of the alert rule: "global" or a provider name. */ + scope: string; + /** Actual spend at the time the notification was generated. */ + spend: number; + /** Configured threshold that was crossed. */ + threshold: number; + createdAt: string; + readAt: string | null; +} + +// ─── Storage ────────────────────────────────────────────────────────────────── + +const NOTIF_KEY = "veylant_notifications"; + +/** Custom event dispatched whenever notifications are saved, so sibling components can react. */ +export const NOTIF_CHANGE_EVENT = "veylant:notifications"; + +export function loadNotifications(): AppNotification[] { + try { + return JSON.parse(localStorage.getItem(NOTIF_KEY) ?? "[]"); + } catch { + return []; + } +} + +/** Persist notifications (max 100) and broadcast the change event. */ +export function saveNotifications(notifs: AppNotification[]): void { + localStorage.setItem(NOTIF_KEY, JSON.stringify(notifs.slice(0, 100))); + window.dispatchEvent(new Event(NOTIF_CHANGE_EVENT)); +} + +export function getUnreadCount(): number { + return loadNotifications().filter((n) => !n.readAt).length; +} diff --git a/web/src/pages/AlertsPage.tsx b/web/src/pages/AlertsPage.tsx index 54a04f2..ca8fc04 100644 --- a/web/src/pages/AlertsPage.tsx +++ b/web/src/pages/AlertsPage.tsx @@ -1,9 +1,8 @@ import { useState, useEffect } from "react"; -import { Bell, Plus, Trash2, TriangleAlert } from "lucide-react"; +import { Bell, Plus, Trash2, TriangleAlert, TrendingUp } from "lucide-react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -15,6 +14,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; import { useCosts } from "@/api/costs"; interface BudgetAlert { @@ -51,8 +51,17 @@ export function AlertsPage() { const [alerts, setAlerts] = useState(loadAlerts); const [showForm, setShowForm] = useState(false); - const { data: costData } = useCosts({}, 30_000); - const totalCost = costData?.data.reduce((s, c) => s + c.total_cost_usd, 0) ?? 0; + const { data: costData } = useCosts({ group_by: "provider" }, 30_000); + const providerRows = costData?.data ?? []; + const costByProvider = Object.fromEntries( + providerRows.map((c) => [c.key, c.total_cost_usd]) + ); + const totalCost = providerRows.reduce((s, c) => s + c.total_cost_usd, 0); + const totalRequests = providerRows.reduce((s, c) => s + c.request_count, 0); + + function getSpendForScope(scope: string): number { + return scope === "global" ? totalCost : (costByProvider[scope] ?? 0); + } const form = useForm({ resolver: zodResolver(alertSchema), @@ -64,14 +73,14 @@ export function AlertsPage() { }, [alerts]); const handleCreate = form.handleSubmit((data) => { - const alert: BudgetAlert = { + const newAlert: BudgetAlert = { id: crypto.randomUUID(), name: data.name, scope: data.scope, threshold: data.threshold, createdAt: new Date().toISOString(), }; - setAlerts((prev) => [...prev, alert]); + setAlerts((prev) => [...prev, newAlert]); form.reset(); setShowForm(false); }); @@ -80,130 +89,219 @@ export function AlertsPage() { setAlerts((prev) => prev.filter((a) => a.id !== id)); }; - const triggeredAlerts = alerts.filter((a) => totalCost >= a.threshold); + const triggeredCount = alerts.filter( + (a) => getSpendForScope(a.scope) >= a.threshold + ).length; return ( -
+
+ {/* Header */}

Alertes Budget

- Soyez notifié quand vos dépenses IA dépassent un seuil + Règles de seuil — les notifications apparaissent dans la cloche en haut à droite

-
- {/* Triggered alerts */} - {triggeredAlerts.length > 0 && ( - - - - {triggeredAlerts.length} alerte{triggeredAlerts.length > 1 ? "s" : ""} déclenchée - {triggeredAlerts.length > 1 ? "s" : ""} - - - Coût actuel : ${totalCost.toFixed(4)} — dépasse les seuils :{" "} - {triggeredAlerts.map((a) => a.name).join(", ")} - - - )} - - {/* Current spend */} -
-

Dépenses totales (période actuelle)

-

${totalCost.toFixed(4)}

-

USD · Mis à jour automatiquement

+ {/* Status strip */} +
+
+

Total dépensé

+

${totalCost.toFixed(6)}

+

+ {totalRequests.toLocaleString("fr-FR")} req · 30 s +

+
+ {providerRows.slice(0, 3).map((c) => ( +
+

{c.key}

+

${c.total_cost_usd.toFixed(6)}

+

{c.request_count} req.

+
+ ))}
+ {/* Summary badges */} + {alerts.length > 0 && ( +
+ + {alerts.length} règle{alerts.length > 1 ? "s" : ""} configurée + {alerts.length > 1 ? "s" : ""} + + {triggeredCount > 0 && ( + + + {triggeredCount} déclenchée{triggeredCount > 1 ? "s" : ""} + + )} + {triggeredCount === 0 && ( + + Tout OK + + )} +
+ )} + {/* Create form */} {showForm && ( -
-

Nouvelle alerte budget

-
- - -
-
-
- - -
-
- + <> + + +

Nouvelle règle d'alerte

+
+ + {form.formState.errors.name && ( +

+ {form.formState.errors.name.message} +

+ )}
-
-
- - -
- +
+
+ + +
+
+ + + {form.formState.errors.threshold && ( +

+ {form.formState.errors.threshold.message} +

+ )} +
+
+
+ + +
+ + )} - {/* Alerts list */} + {/* Rules list */} + + {alerts.length === 0 ? ( -
+
-

Aucune alerte configurée

+

Aucune règle configurée

+

+ Créez une règle pour générer des notifications dans la cloche +

) : ( -
+
{alerts.map((alert) => { - const isTriggered = totalCost >= alert.threshold; + const spend = getSpendForScope(alert.scope); + const pct = + alert.threshold > 0 + ? Math.min((spend / alert.threshold) * 100, 100) + : 0; + const isTriggered = spend >= alert.threshold; + const isWarning = !isTriggered && pct >= 80; + return ( -
-
- -
-

{alert.name}

-

- {alert.scope} · Seuil : ${alert.threshold.toFixed(2)} -

+
+
+
+ {isTriggered ? ( + + ) : isWarning ? ( + + ) : ( + + )} +
+

{alert.name}

+

+ {alert.scope === "global" ? "Tous fournisseurs" : alert.scope} · seuil $ + {alert.threshold.toFixed(6)} +

+
+
+
+ + {isTriggered ? "Déclenchée" : isWarning ? "Attention" : "OK"} + +
-
- - {isTriggered ? "Déclenchée" : "OK"} - - + + {/* Progress bar */} +
+
+ ${spend.toFixed(6)} + {pct.toFixed(1)} % du seuil + ${alert.threshold.toFixed(6)} +
+
+
+
); diff --git a/web/src/pages/CostsPage.tsx b/web/src/pages/CostsPage.tsx index 79c14dc..ca3c5aa 100644 --- a/web/src/pages/CostsPage.tsx +++ b/web/src/pages/CostsPage.tsx @@ -52,7 +52,7 @@ export function CostsPage() { const { data: byDept, isLoading: loadingDept } = useCosts({ group_by: "department" }, 60_000); const [alerts] = useState(loadAlerts); - const totalCost = byModel?.data.reduce((s, c) => s + c.total_cost_usd, 0) ?? 0; + const totalCost = byModel?.data?.reduce((s, c) => s + c.total_cost_usd, 0) ?? 0; const projection = projectMonthEnd(totalCost); const maxAlert = alerts.length > 0 ? Math.min(...alerts.map((a) => a.threshold)) : null; const budgetPct = maxAlert ? (totalCost / maxAlert) * 100 : null; @@ -87,10 +87,10 @@ export function CostsPage() { {overBudget && ( - Alerte budget : {budgetPct!.toFixed(0)}% du seuil atteint + Alerte budget : {budgetPct!.toFixed(1)}% du seuil atteint - Dépenses actuelles : ${totalCost.toFixed(4)} sur un seuil de{" "} - ${maxAlert!.toFixed(2)}. + Dépenses actuelles : ${totalCost.toFixed(6)} sur un seuil de{" "} + ${maxAlert!.toFixed(6)}. )} @@ -99,11 +99,11 @@ export function CostsPage() {

Total période

-

${totalCost.toFixed(4)}

+

${totalCost.toFixed(6)}

Projection fin de mois

-

${projection.toFixed(4)}

+

${projection.toFixed(6)}

Extrapolation linéaire

@@ -118,15 +118,15 @@ export function CostsPage() { variant={overBudget ? "destructive" : "secondary"} className="ml-2 text-xs" > - {budgetPct!.toFixed(0)}% + {budgetPct!.toFixed(1)}% )}

- {maxAlert ? `$${maxAlert.toFixed(2)}` : "—"} + ${totalCost.toFixed(6)}

- {maxAlert ? "Seuil configuré" : "Aucune alerte"} + {maxAlert ? `Seuil : $${maxAlert.toFixed(6)}` : "Aucune alerte configurée"}

@@ -212,8 +212,8 @@ export function CostsPage() { {c.total_tokens.toLocaleString()} tok. - - ${c.total_cost_usd.toFixed(4)} + + ${c.total_cost_usd.toFixed(6)}
sum + c.total_cost_usd, 0) ?? 0; - const totalTokens = costData?.data.reduce((sum, c) => sum + c.total_tokens, 0) ?? 0; - const requestCountByProvider = costData?.data.length ?? 0; + const totalCost = costData?.data?.reduce((sum, c) => sum + c.total_cost_usd, 0) ?? 0; + const totalTokens = costData?.data?.reduce((sum, c) => sum + c.total_tokens, 0) ?? 0; + const requestCountByProvider = costData?.data?.length ?? 0; const lastUpdated = dataUpdatedAt ? format(new Date(dataUpdatedAt), "HH:mm:ss", { locale: fr }) @@ -41,7 +41,7 @@ export function OverviewPage() { /> {/* Cost by provider */} - {costData && costData.data.length > 0 && ( + {(costData?.data?.length ?? 0) > 0 && (

Coûts par fournisseur

@@ -78,7 +78,7 @@ export function OverviewPage() { {c.request_count.toLocaleString("fr-FR")} req - ${c.total_cost_usd.toFixed(4)} + ${c.total_cost_usd.toFixed(6)}
))} diff --git a/web/src/pages/SecurityPage.tsx b/web/src/pages/SecurityPage.tsx index 945692a..6e5b439 100644 --- a/web/src/pages/SecurityPage.tsx +++ b/web/src/pages/SecurityPage.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { ShieldAlert, Download } from "lucide-react"; import { BarChart, @@ -47,7 +47,8 @@ const PERIOD_OPTIONS = [ function getPeriodStart(days: number): string { const d = new Date(); - d.setDate(d.getDate() - days); + d.setUTCDate(d.getUTCDate() - days); + d.setUTCHours(0, 0, 0, 0); // Round to start of UTC day — stable queryKey, better caching return d.toISOString(); } @@ -96,12 +97,25 @@ function exportCSV(entries: AuditEntry[]) { export function SecurityPage() { const [period, setPeriod] = useState("30"); - const start = getPeriodStart(parseInt(period, 10)); + // Memoised so queryKey stays stable across re-renders for the same period. + const start = useMemo(() => getPeriodStart(parseInt(period, 10)), [period]); - const { data, isLoading } = useAuditLogs({ start, limit: 500 }, 60_000); + const { data, isLoading, error } = useAuditLogs({ start, limit: 500 }, 60_000); const entries = data?.data ?? []; const blockedEntries = entries.filter((e) => e.status !== "ok"); + + if (error) { + return ( +
+ +

Impossible de charger les logs de sécurité

+

+ {error instanceof Error ? error.message : "Erreur inconnue"} +

+
+ ); + } const chartData = buildSensitivityChart(entries); const topUsers = buildTopUsers(entries); diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..26229b8 --- /dev/null +++ b/web/src/pages/SettingsPage.tsx @@ -0,0 +1,552 @@ +import { useState, useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { User, Lock, Bell, Palette, Save, Check, Eye, EyeOff, Mail } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAuth } from "@/auth/AuthProvider"; +import { useListUsers, useUpdateUser } from "@/api/users"; + +// ─── Schemas ────────────────────────────────────────────────────────────────── + +const profileSchema = z.object({ + name: z.string().min(1, "Le nom est requis"), +}); + +const passwordSchema = z + .object({ + password: z.string().min(8, "Minimum 8 caractères"), + confirmPassword: z.string(), + }) + .refine((d) => d.password === d.confirmPassword, { + message: "Les mots de passe ne correspondent pas", + path: ["confirmPassword"], + }); + +type ProfileFormData = z.infer; +type PasswordFormData = z.infer; + +// ─── Theme helpers ──────────────────────────────────────────────────────────── + +function getStoredTheme(): "light" | "dark" { + return (localStorage.getItem("veylant_theme") as "light" | "dark") ?? "light"; +} + +function applyTheme(theme: "light" | "dark") { + localStorage.setItem("veylant_theme", theme); + if (theme === "dark") { + document.documentElement.classList.add("dark"); + } else { + document.documentElement.classList.remove("dark"); + } +} + +// ─── Section nav ────────────────────────────────────────────────────────────── + +const SECTIONS = [ + { id: "profile", label: "Profil", icon: User }, + { id: "security", label: "Sécurité", icon: Lock }, + { id: "notifications", label: "Notifications", icon: Bell }, + { id: "appearance", label: "Apparence", icon: Palette }, +] as const; + +type SectionId = (typeof SECTIONS)[number]["id"]; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function SettingsPage() { + const { user } = useAuth(); + const { data: users, isLoading: usersLoading } = useListUsers(); + const updateUser = useUpdateUser(); + + const [activeSection, setActiveSection] = useState("profile"); + const [profileSaved, setProfileSaved] = useState(false); + const [passwordSaved, setPasswordSaved] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [theme, setTheme] = useState<"light" | "dark">(getStoredTheme); + + // Notification preferences stored in localStorage + const [notifBudget, setNotifBudget] = useState( + () => localStorage.getItem("veylant_notif_budget") !== "false" + ); + const [notifSecurity, setNotifSecurity] = useState( + () => localStorage.getItem("veylant_notif_security") !== "false" + ); + const [notifEmail, setNotifEmail] = useState( + () => localStorage.getItem("veylant_notif_email") ?? "" + ); + const [emailSaved, setEmailSaved] = useState(false); + + // Find the current user's full record from the API (to get department, role, is_active) + const currentUserRecord = users?.find((u) => u.id === user?.id); + + const profileForm = useForm({ + resolver: zodResolver(profileSchema), + defaultValues: { name: user?.name ?? "" }, + }); + + const passwordForm = useForm({ + resolver: zodResolver(passwordSchema), + defaultValues: { password: "", confirmPassword: "" }, + }); + + // Sync name field once the user record loads from API + useEffect(() => { + if (currentUserRecord) { + profileForm.reset({ name: currentUserRecord.name }); + } + }, [currentUserRecord, profileForm]); + + const handleProfileSave = profileForm.handleSubmit(async (data) => { + if (!user || !currentUserRecord) return; + await updateUser.mutateAsync({ + id: user.id, + email: currentUserRecord.email, + name: data.name, + department: currentUserRecord.department, + role: currentUserRecord.role, + is_active: currentUserRecord.is_active, + }); + setProfileSaved(true); + setTimeout(() => setProfileSaved(false), 2500); + }); + + const handlePasswordSave = passwordForm.handleSubmit(async (data) => { + if (!user || !currentUserRecord) return; + await updateUser.mutateAsync({ + id: user.id, + email: currentUserRecord.email, + name: currentUserRecord.name, + department: currentUserRecord.department, + role: currentUserRecord.role, + is_active: currentUserRecord.is_active, + password: data.password, + }); + passwordForm.reset(); + setPasswordSaved(true); + setTimeout(() => setPasswordSaved(false), 2500); + }); + + const handleThemeChange = (dark: boolean) => { + const t = dark ? "dark" : "light"; + setTheme(t); + applyTheme(t); + }; + + // Decode token expiry for display + let tokenExpiry: string | null = null; + if (user?.token) { + try { + const payload = JSON.parse( + atob(user.token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")) + ) as { exp?: number }; + if (payload.exp) { + tokenExpiry = new Date(payload.exp * 1000).toLocaleString("fr-FR"); + } + } catch { + // non-blocking + } + } + + const initials = (user?.name ?? user?.email ?? "?")[0].toUpperCase(); + + return ( +
+
+

Paramètres

+

+ Gérez votre compte et vos préférences +

+
+ +
+ {/* Left section nav */} + + + {/* Section content */} +
+ {/* ── Profil ──────────────────────────────────────────────────────── */} + {activeSection === "profile" && ( + <> +
+

Informations du profil

+ + {/* Avatar + identity */} +
+
+ {initials} +
+
+ {usersLoading ? ( + + ) : ( +

+ {currentUserRecord?.name ?? user?.name ?? user?.email} +

+ )} +

{user?.email}

+
+ {user?.roles.map((r) => ( + + {r} + + ))} + {currentUserRecord?.department && ( + + {currentUserRecord.department} + + )} +
+
+
+ + + + {/* Name edit form */} +
+
+
+ + + {profileForm.formState.errors.name && ( +

+ {profileForm.formState.errors.name.message} +

+ )} +
+
+ + +
+
+ + {updateUser.error && ( +

+ {updateUser.error instanceof Error + ? updateUser.error.message + : "Erreur lors de la mise à jour"} +

+ )} +
+
+ + {/* Password change */} +
+

Changer le mot de passe

+
+
+
+ +
+ + +
+ {passwordForm.formState.errors.password && ( +

+ {passwordForm.formState.errors.password.message} +

+ )} +
+
+ + + {passwordForm.formState.errors.confirmPassword && ( +

+ {passwordForm.formState.errors.confirmPassword.message} +

+ )} +
+
+ +
+
+ + )} + + {/* ── Sécurité ────────────────────────────────────────────────────── */} + {activeSection === "security" && ( +
+
+

Session active

+
+
+
Identifiant
+
{user?.id ?? "—"}
+
+ +
+
Email
+
{user?.email ?? "—"}
+
+ +
+
Rôle
+
{user?.roles[0] ?? "—"}
+
+ {currentUserRecord?.department && ( + <> + +
+
Département
+
{currentUserRecord.department}
+
+ + )} + {tokenExpiry && ( + <> + +
+
Expiration du token
+
{tokenExpiry}
+
+ + )} +
+
+ +
+

Méthode d'authentification

+
+
+ Email + mot de passe · JWT HS256 +
+

+ Les tokens sont signés avec HS256 et expirent automatiquement selon la + configuration du serveur. À la déconnexion, le token est supprimé du navigateur. +

+
+
+ )} + + {/* ── Notifications ────────────────────────────────────────────────── */} + {activeSection === "notifications" && ( +
+ {/* Email notifications */} +
+
+ +

Notifications par email

+
+

+ Recevez une copie de chaque notification (seuil dépassé, alerte 80 %) à l'adresse + ci-dessous. Nécessite la configuration SMTP côté serveur + (config.yaml → notifications.smtp). +

+ +
+ +
+ setNotifEmail(e.target.value)} + placeholder="equipe-finance@entreprise.com" + className="flex-1" + /> + +
+ {notifEmail && ( +

+ + Email configuré — les notifications seront envoyées à{" "} + {notifEmail} +

+ )} + {!notifEmail && ( +

+ Laissez vide pour désactiver les notifications email. +

+ )} +
+ + {notifEmail && ( +
+

+ Supprimer l'email configuré +

+ +
+ )} +
+ + {/* In-app notification toggles */} +
+

Notifications dans l'application

+

+ Ces préférences contrôlent la cloche de notifications dans l'en-tête. +

+ +
+
+
+

Alertes budget

+

+ Notification quand un seuil de dépense est atteint ou dépassé +

+
+ { + setNotifBudget(v); + localStorage.setItem("veylant_notif_budget", String(v)); + }} + /> +
+ +
+
+

Alertes sécurité

+

+ Notifications pour les requêtes de sensibilité critique ou haute +

+
+ { + setNotifSecurity(v); + localStorage.setItem("veylant_notif_security", String(v)); + }} + /> +
+
+
+
+ )} + + {/* ── Apparence ───────────────────────────────────────────────────── */} + {activeSection === "appearance" && ( +
+

Apparence

+ +
+
+
+

Mode sombre

+

+ Basculer vers un thème sombre pour réduire la fatigue visuelle +

+
+ +
+ +
+

Langue de l'interface

+

+ Français (FR) — seule langue disponible en V1 +

+
+ +
+

Format de date

+

+ JJ/MM/AAAA · HH:mm — locale française +

+
+
+
+ )} +
+
+
+ ); +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 5071d4b..ade2f7b 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -13,6 +13,7 @@ import { PlaygroundPage } from "@/pages/PlaygroundPage"; import { SecurityPage } from "@/pages/SecurityPage"; import { CostsPage } from "@/pages/CostsPage"; import { CompliancePage } from "@/pages/CompliancePage"; +import { SettingsPage } from "@/pages/SettingsPage"; import { NotFoundPage } from "@/pages/NotFoundPage"; // Documentation site @@ -77,6 +78,7 @@ export const router = createBrowserRouter([ { path: "alerts", element: }, { path: "logs", element: }, { path: "compliance", element: }, + { path: "settings", element: }, { path: "*", element: }, ], },