fix error for config
This commit is contained in:
parent
410ae18d2d
commit
b30c1147ea
29
CLAUDE.md
29
CLAUDE.md
@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
**Veylant IA** — A B2B SaaS platform acting as an intelligent proxy/gateway for enterprise AI consumption. Core value proposition: prevent Shadow AI, enforce PII anonymization, ensure GDPR/EU AI Act compliance, and control costs across all LLM usage in an organization.
|
**Veylant IA** — A B2B SaaS platform acting as an intelligent proxy/gateway for enterprise AI consumption. Core value proposition: prevent Shadow AI, enforce PII anonymization, ensure GDPR/EU AI Act compliance, and control costs across all LLM usage in an organization.
|
||||||
|
|
||||||
Full product requirements are in `docs/AI_Governance_Hub_PRD.md` and the 6-month execution plan (13 sprints, 164 tasks) is in `docs/AI_Governance_Hub_Plan_Realisation.md`.
|
Full product requirements are in `docs/AI_Governance_Hub_PRD.md` and the 6-month execution plan (13 sprints, 164 tasks) is in `docs/AI_Governance_Hub_Plan_Realisation.md`. Architecture Decision Records live in `docs/adr/`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@ -18,7 +18,8 @@ Full product requirements are in `docs/AI_Governance_Hub_PRD.md` and the 6-month
|
|||||||
API Gateway (Traefik)
|
API Gateway (Traefik)
|
||||||
│
|
│
|
||||||
Go Proxy [cmd/proxy] — chi router, zap logger, viper config
|
Go Proxy [cmd/proxy] — chi router, zap logger, viper config
|
||||||
├── internal/middleware/ Auth (OIDC/Keycloak), RateLimit, RequestID, SecurityHeaders
|
├── internal/auth/ Local JWT auth (HS256) — LocalJWTVerifier + LoginHandler (POST /v1/auth/login)
|
||||||
|
├── internal/middleware/ Auth (JWT verification), RateLimit, RequestID, SecurityHeaders
|
||||||
├── internal/router/ RBAC enforcement + provider dispatch + fallback chain
|
├── internal/router/ RBAC enforcement + provider dispatch + fallback chain
|
||||||
├── internal/routing/ Rules engine (PostgreSQL JSONB, in-memory cache, priority ASC)
|
├── internal/routing/ Rules engine (PostgreSQL JSONB, in-memory cache, priority ASC)
|
||||||
├── internal/pii/ gRPC client to PII sidecar + /v1/pii/analyze HTTP handler
|
├── internal/pii/ gRPC client to PII sidecar + /v1/pii/analyze HTTP handler
|
||||||
@ -50,11 +51,10 @@ LLM Provider Adapters (OpenAI, Anthropic, Azure, Mistral, Ollama)
|
|||||||
- PostgreSQL 16 — config, users, policies, processing registry (Row-Level Security for multi-tenancy; app role: `veylant_app`)
|
- PostgreSQL 16 — config, users, policies, processing registry (Row-Level Security for multi-tenancy; app role: `veylant_app`)
|
||||||
- ClickHouse — analytics and immutable audit logs
|
- ClickHouse — analytics and immutable audit logs
|
||||||
- Redis 7 — sessions, rate limiting, PII pseudonymization mappings (AES-256-GCM + TTL)
|
- Redis 7 — sessions, rate limiting, PII pseudonymization mappings (AES-256-GCM + TTL)
|
||||||
- Keycloak — IAM, SSO, SAML 2.0/OIDC federation (dev console: http://localhost:8080, admin/admin; test users: admin@veylant.dev/admin123, user@veylant.dev/user123)
|
|
||||||
- Prometheus — metrics scraper on :9090; Grafana — dashboards on :3001 (admin/admin)
|
- Prometheus — metrics scraper on :9090; Grafana — dashboards on :3001 (admin/admin)
|
||||||
- HashiCorp Vault — secrets and API key rotation (90-day cycle)
|
- HashiCorp Vault — secrets and API key rotation (90-day cycle)
|
||||||
|
|
||||||
**Frontend:** React 18 + TypeScript + Vite, shadcn/ui, recharts. Routes protected via OIDC (Keycloak); `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/`.
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
@ -111,9 +111,13 @@ make deploy-rollback # Roll back traffic to ACTIVE_SLOT (e.g. make deploy-rollb
|
|||||||
|
|
||||||
**Frontend dev server** (Vite, runs on :3000):
|
**Frontend dev server** (Vite, runs on :3000):
|
||||||
```bash
|
```bash
|
||||||
cd web && npm install && npm run dev
|
cd web && npm install && npm run dev # dev server with HMR
|
||||||
|
cd web && npm run build # tsc + vite build → web/dist/
|
||||||
|
cd web && npm run lint # ESLint (max-warnings: 0)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Vite dev proxy:** In dev mode, all `/v1/*` requests from the frontend are proxied to `localhost:8090` (the Go proxy). No CORS issues during development.
|
||||||
|
|
||||||
**Run a single Go test:**
|
**Run a single Go test:**
|
||||||
```bash
|
```bash
|
||||||
go test -run TestName ./internal/module/
|
go test -run TestName ./internal/module/
|
||||||
@ -128,12 +132,21 @@ pytest services/pii/tests/test_file.py::test_function
|
|||||||
|
|
||||||
**Config override:** Any config key can be overridden via env var with the `VEYLANT_` prefix and `.` → `_` replacement. Example: `VEYLANT_SERVER_PORT=9090` overrides `server.port`.
|
**Config override:** Any config key can be overridden via env var with the `VEYLANT_` prefix and `.` → `_` replacement. Example: `VEYLANT_SERVER_PORT=9090` overrides `server.port`.
|
||||||
|
|
||||||
|
**Auth config:** `auth.jwt_secret` (env: `VEYLANT_AUTH_JWT_SECRET`) and `auth.jwt_ttl_hours`. Login endpoint: `POST /v1/auth/login` (public). Dev credentials: `admin@veylant.dev` / `admin123`. Tokens are HS256-signed JWTs; users stored in `users` table with bcrypt password hashes (migration 000010).
|
||||||
|
|
||||||
|
**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()`).
|
||||||
|
|
||||||
**Tools required:** `buf` (`brew install buf`), `golang-migrate` (`brew install golang-migrate`), `golangci-lint`, Python 3.12, `black`, `ruff`.
|
**Tools required:** `buf` (`brew install buf`), `golang-migrate` (`brew install golang-migrate`), `golangci-lint`, Python 3.12, `black`, `ruff`.
|
||||||
|
|
||||||
|
**Tenant onboarding** (after `make dev`):
|
||||||
|
```bash
|
||||||
|
deploy/onboarding/onboard-tenant.sh # creates admin, seeds 4 routing templates, configures rate limits
|
||||||
|
deploy/onboarding/import-users.sh # bulk import from CSV (email, first_name, last_name, department, role)
|
||||||
|
```
|
||||||
|
|
||||||
## Development Mode Graceful Degradation
|
## Development Mode Graceful Degradation
|
||||||
|
|
||||||
When `server.env=development`, the proxy degrades gracefully instead of crashing:
|
When `server.env=development`, the proxy degrades gracefully instead of crashing:
|
||||||
- **Keycloak unreachable** → falls back to `MockVerifier` (JWT auth bypassed; dev user injected as `admin` role)
|
|
||||||
- **PostgreSQL unreachable** → routing engine and feature flags disabled; flag store uses in-memory fallback
|
- **PostgreSQL unreachable** → routing engine and feature flags disabled; flag store uses in-memory fallback
|
||||||
- **ClickHouse unreachable** → audit logging disabled
|
- **ClickHouse unreachable** → audit logging disabled
|
||||||
- **PII service unreachable** → PII disabled if `pii.fail_open=true` (default)
|
- **PII service unreachable** → PII disabled if `pii.fail_open=true` (default)
|
||||||
@ -191,8 +204,8 @@ These are enforced in CI and represent project-specific guardrails:
|
|||||||
- API keys stored as SHA-256 hashes only; prefix kept for display (e.g. `sk-vyl_ab12cd34`)
|
- API keys stored as SHA-256 hashes only; prefix kept for display (e.g. `sk-vyl_ab12cd34`)
|
||||||
- RBAC roles: `admin`, `manager`, `user`, `auditor` — per-model and per-department permissions. `admin`/`manager` have unrestricted model access; `user` is limited to `rbac.user_allowed_models`; `auditor` cannot call `/v1/chat/completions` by default.
|
- RBAC roles: `admin`, `manager`, `user`, `auditor` — per-model and per-department permissions. `admin`/`manager` have unrestricted model access; `user` is limited to `rbac.user_allowed_models`; `auditor` cannot call `/v1/chat/completions` by default.
|
||||||
- Audit-of-the-audit: all accesses to audit logs are themselves logged
|
- Audit-of-the-audit: all accesses to audit logs are themselves logged
|
||||||
- CI pipeline: Semgrep (SAST), Trivy (image scanning, CRITICAL/HIGH blocking), gitleaks (secret detection), OWASP ZAP DAST (non-blocking, main branch only)
|
- CI pipeline (`.github/workflows/ci.yml`): Go build/test/lint, Python format/lint/test, Semgrep SAST, Trivy container scan (CRITICAL/HIGH blocking), gitleaks, OWASP ZAP DAST (non-blocking, main only), k6 smoke test + blue/green Helm staging deploy (main only)
|
||||||
- Release pipeline (`v*` tag push): multi-arch Docker image (amd64/arm64) → GHCR, Helm chart → GHCR OCI, GitHub Release with notes extracted from CHANGELOG.md
|
- Release pipeline (`.github/workflows/release.yml`, on `v*` tag): multi-arch Docker image (amd64/arm64) → GHCR, Helm chart → GHCR OCI, GitHub Release with notes extracted from CHANGELOG.md
|
||||||
|
|
||||||
## MVP Scope (V1)
|
## MVP Scope (V1)
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import (
|
|||||||
_ "github.com/jackc/pgx/v5/stdlib" // register pgx driver
|
_ "github.com/jackc/pgx/v5/stdlib" // register pgx driver
|
||||||
|
|
||||||
"github.com/veylant/ia-gateway/internal/admin"
|
"github.com/veylant/ia-gateway/internal/admin"
|
||||||
|
"github.com/veylant/ia-gateway/internal/auth"
|
||||||
"github.com/veylant/ia-gateway/internal/auditlog"
|
"github.com/veylant/ia-gateway/internal/auditlog"
|
||||||
"github.com/veylant/ia-gateway/internal/circuitbreaker"
|
"github.com/veylant/ia-gateway/internal/circuitbreaker"
|
||||||
"github.com/veylant/ia-gateway/internal/compliance"
|
"github.com/veylant/ia-gateway/internal/compliance"
|
||||||
@ -52,21 +53,10 @@ func main() {
|
|||||||
logger := buildLogger(cfg.Log.Level, cfg.Log.Format)
|
logger := buildLogger(cfg.Log.Level, cfg.Log.Format)
|
||||||
defer logger.Sync() //nolint:errcheck
|
defer logger.Sync() //nolint:errcheck
|
||||||
|
|
||||||
// ── JWT / OIDC verifier ───────────────────────────────────────────────────
|
// ── Local JWT verifier (email/password auth — replaces Keycloak OIDC) ────
|
||||||
issuerURL := fmt.Sprintf("%s/realms/%s", cfg.Keycloak.BaseURL, cfg.Keycloak.Realm)
|
|
||||||
logger.Info("initialising OIDC verifier", zap.String("issuer", issuerURL))
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
oidcVerifier, err := middleware.NewOIDCVerifier(ctx, issuerURL, cfg.Keycloak.ClientID)
|
jwtVerifier := auth.NewLocalJWTVerifier(cfg.Auth.JWTSecret)
|
||||||
if err != nil {
|
logger.Info("local JWT verifier initialised")
|
||||||
if cfg.Server.Env == "development" {
|
|
||||||
logger.Warn("OIDC verifier unavailable — JWT auth will reject all requests",
|
|
||||||
zap.Error(err))
|
|
||||||
oidcVerifier = nil
|
|
||||||
} else {
|
|
||||||
logger.Fatal("failed to initialise OIDC verifier", zap.Error(err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── LLM provider adapters ─────────────────────────────────────────────────
|
// ── LLM provider adapters ─────────────────────────────────────────────────
|
||||||
adapters := map[string]provider.Adapter{}
|
adapters := map[string]provider.Adapter{}
|
||||||
@ -199,6 +189,11 @@ func main() {
|
|||||||
logger.Warn("VEYLANT_CRYPTO_AES_KEY_BASE64 not set — prompt encryption disabled")
|
logger.Warn("VEYLANT_CRYPTO_AES_KEY_BASE64 not set — prompt encryption disabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Load provider configs from DB (supplements static config-based adapters) ─
|
||||||
|
if db != nil {
|
||||||
|
loadProvidersFromDB(ctx, db, providerRouter, encryptor, logger)
|
||||||
|
}
|
||||||
|
|
||||||
// ── ClickHouse audit logger (optional) ────────────────────────────────────
|
// ── ClickHouse audit logger (optional) ────────────────────────────────────
|
||||||
var auditLogger auditlog.Logger
|
var auditLogger auditlog.Logger
|
||||||
if cfg.ClickHouse.DSN != "" {
|
if cfg.ClickHouse.DSN != "" {
|
||||||
@ -298,23 +293,13 @@ func main() {
|
|||||||
r.Get(cfg.Metrics.Path, promhttp.Handler().ServeHTTP)
|
r.Get(cfg.Metrics.Path, promhttp.Handler().ServeHTTP)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public login endpoint — must be registered before the auth middleware below.
|
||||||
|
loginHandler := auth.NewLoginHandler(db, cfg.Auth.JWTSecret, cfg.Auth.JWTTTLHours, logger)
|
||||||
|
r.Post("/v1/auth/login", loginHandler.ServeHTTP)
|
||||||
|
|
||||||
r.Route("/v1", func(r chi.Router) {
|
r.Route("/v1", func(r chi.Router) {
|
||||||
r.Use(middleware.CORS(cfg.Server.AllowedOrigins))
|
r.Use(middleware.CORS(cfg.Server.AllowedOrigins))
|
||||||
var authMW func(http.Handler) http.Handler
|
r.Use(middleware.Auth(jwtVerifier))
|
||||||
if oidcVerifier != nil {
|
|
||||||
authMW = middleware.Auth(oidcVerifier)
|
|
||||||
} else {
|
|
||||||
authMW = middleware.Auth(&middleware.MockVerifier{
|
|
||||||
Claims: &middleware.UserClaims{
|
|
||||||
UserID: "dev-user",
|
|
||||||
TenantID: "00000000-0000-0000-0000-000000000001",
|
|
||||||
Email: "dev@veylant.local",
|
|
||||||
Roles: []string{"admin"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
logger.Warn("running in DEV mode — JWT validation is DISABLED")
|
|
||||||
}
|
|
||||||
r.Use(authMW)
|
|
||||||
r.Use(middleware.RateLimit(rateLimiter))
|
r.Use(middleware.RateLimit(rateLimiter))
|
||||||
r.Post("/chat/completions", proxyHandler.ServeHTTP)
|
r.Post("/chat/completions", proxyHandler.ServeHTTP)
|
||||||
|
|
||||||
@ -340,8 +325,8 @@ func main() {
|
|||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Wire db, router, rate limiter, and feature flags (Sprint 8 + Sprint 10 + Sprint 11).
|
// Wire db, router, rate limiter, feature flags, and encryptor.
|
||||||
adminHandler.WithDB(db).WithRouter(providerRouter).WithRateLimiter(rateLimiter).WithFlagStore(flagStore)
|
adminHandler.WithDB(db).WithRouter(providerRouter).WithRateLimiter(rateLimiter).WithFlagStore(flagStore).WithEncryptor(encryptor)
|
||||||
r.Route("/admin", adminHandler.Routes)
|
r.Route("/admin", adminHandler.Routes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -375,7 +360,7 @@ func main() {
|
|||||||
zap.String("addr", addr),
|
zap.String("addr", addr),
|
||||||
zap.String("env", cfg.Server.Env),
|
zap.String("env", cfg.Server.Env),
|
||||||
zap.Bool("metrics", cfg.Metrics.Enabled),
|
zap.Bool("metrics", cfg.Metrics.Enabled),
|
||||||
zap.String("oidc_issuer", issuerURL),
|
zap.String("auth", "local-jwt"),
|
||||||
zap.Bool("audit_logging", auditLogger != nil),
|
zap.Bool("audit_logging", auditLogger != nil),
|
||||||
zap.Bool("encryption", encryptor != nil),
|
zap.Bool("encryption", encryptor != nil),
|
||||||
)
|
)
|
||||||
@ -402,6 +387,61 @@ func main() {
|
|||||||
logger.Info("server stopped cleanly")
|
logger.Info("server stopped cleanly")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadProvidersFromDB reads active provider_configs from the database and
|
||||||
|
// registers/updates adapters on the router. Called once at startup so DB-saved
|
||||||
|
// configs take precedence over (or extend) the static config.yaml ones.
|
||||||
|
func loadProvidersFromDB(ctx context.Context, db *sql.DB, providerRouter *router.Router, enc *crypto.Encryptor, logger *zap.Logger) {
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT provider, api_key_enc, base_url, resource_name, deployment_id,
|
||||||
|
api_version, timeout_sec, max_conns, model_patterns
|
||||||
|
FROM provider_configs WHERE is_active = TRUE`)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("failed to load provider configs from DB", zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
prov, apiKeyEnc, baseURL, resourceName, deploymentID, apiVersion string
|
||||||
|
timeoutSec, maxConns int
|
||||||
|
patternsRaw string
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&prov, &apiKeyEnc, &baseURL, &resourceName,
|
||||||
|
&deploymentID, &apiVersion, &timeoutSec, &maxConns, &patternsRaw); err != nil {
|
||||||
|
logger.Warn("failed to scan provider config row", zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt API key
|
||||||
|
apiKey := apiKeyEnc
|
||||||
|
if enc != nil && apiKeyEnc != "" {
|
||||||
|
if dec, decErr := enc.Decrypt(apiKeyEnc); decErr == nil {
|
||||||
|
apiKey = dec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &admin.ProviderConfig{
|
||||||
|
Provider: prov,
|
||||||
|
BaseURL: baseURL,
|
||||||
|
ResourceName: resourceName,
|
||||||
|
DeploymentID: deploymentID,
|
||||||
|
APIVersion: apiVersion,
|
||||||
|
TimeoutSec: timeoutSec,
|
||||||
|
MaxConns: maxConns,
|
||||||
|
}
|
||||||
|
adapter := admin.BuildProviderAdapter(cfg, apiKey)
|
||||||
|
if adapter != nil {
|
||||||
|
providerRouter.UpdateAdapter(prov, adapter)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
logger.Info("provider configs loaded from DB", zap.Int("count", count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func buildLogger(level, format string) *zap.Logger {
|
func buildLogger(level, format string) *zap.Logger {
|
||||||
lvl := zap.InfoLevel
|
lvl := zap.InfoLevel
|
||||||
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
||||||
|
|||||||
@ -17,10 +17,11 @@ database:
|
|||||||
redis:
|
redis:
|
||||||
url: "redis://localhost:6379"
|
url: "redis://localhost:6379"
|
||||||
|
|
||||||
keycloak:
|
# Local JWT authentication (email/password — replaces Keycloak).
|
||||||
base_url: "http://localhost:8080"
|
# Override jwt_secret in production via VEYLANT_AUTH_JWT_SECRET.
|
||||||
realm: "veylant"
|
auth:
|
||||||
client_id: "veylant-proxy"
|
jwt_secret: "change-me-in-production-use-VEYLANT_AUTH_JWT_SECRET"
|
||||||
|
jwt_ttl_hours: 24
|
||||||
|
|
||||||
pii:
|
pii:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@ -63,30 +63,6 @@ services:
|
|||||||
soft: 262144
|
soft: 262144
|
||||||
hard: 262144
|
hard: 262144
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
# Keycloak 24 — IAM, OIDC, SAML 2.0
|
|
||||||
# start-dev: in-memory DB, no TLS — development only
|
|
||||||
# Realm is auto-imported from deploy/keycloak/realm-export.json
|
|
||||||
# ─────────────────────────────────────────────
|
|
||||||
keycloak:
|
|
||||||
image: quay.io/keycloak/keycloak:24.0
|
|
||||||
command: ["start-dev", "--import-realm"]
|
|
||||||
environment:
|
|
||||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
|
||||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
|
||||||
KC_DB: dev-mem
|
|
||||||
KC_HEALTH_ENABLED: "true"
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- ./deploy/keycloak:/opt/keycloak/data/import:ro
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "curl -sf http://localhost:8080/health/ready || exit 1"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 20
|
|
||||||
start_period: 30s # Keycloak takes ~20s to start in dev mode
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Veylant proxy — Go application
|
# Veylant proxy — Go application
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
@ -101,9 +77,9 @@ services:
|
|||||||
VEYLANT_SERVER_ENV: "development"
|
VEYLANT_SERVER_ENV: "development"
|
||||||
VEYLANT_DATABASE_URL: "postgres://veylant:veylant_dev@postgres:5432/veylant?sslmode=disable"
|
VEYLANT_DATABASE_URL: "postgres://veylant:veylant_dev@postgres:5432/veylant?sslmode=disable"
|
||||||
VEYLANT_REDIS_URL: "redis://redis:6379"
|
VEYLANT_REDIS_URL: "redis://redis:6379"
|
||||||
VEYLANT_KEYCLOAK_BASE_URL: "http://keycloak:8080"
|
# Local JWT secret — override in production: VEYLANT_AUTH_JWT_SECRET=<strong-secret>
|
||||||
VEYLANT_KEYCLOAK_REALM: "veylant"
|
VEYLANT_AUTH_JWT_SECRET: "${VEYLANT_AUTH_JWT_SECRET:-dev-jwt-secret-change-in-prod}"
|
||||||
VEYLANT_KEYCLOAK_CLIENT_ID: "veylant-proxy"
|
VEYLANT_AUTH_JWT_TTL_HOURS: "24"
|
||||||
VEYLANT_PII_ENABLED: "true"
|
VEYLANT_PII_ENABLED: "true"
|
||||||
VEYLANT_PII_SERVICE_ADDR: "pii:50051"
|
VEYLANT_PII_SERVICE_ADDR: "pii:50051"
|
||||||
VEYLANT_PII_TIMEOUT_MS: "100"
|
VEYLANT_PII_TIMEOUT_MS: "100"
|
||||||
@ -155,10 +131,10 @@ services:
|
|||||||
PII_HTTP_PORT: "8000"
|
PII_HTTP_PORT: "8000"
|
||||||
PII_REDIS_URL: "redis://redis:6379"
|
PII_REDIS_URL: "redis://redis:6379"
|
||||||
# PII_ENCRYPTION_KEY must be set to a 32-byte base64-encoded key in production.
|
# PII_ENCRYPTION_KEY must be set to a 32-byte base64-encoded key in production.
|
||||||
# The default dev key is used if unset (NOT safe for production).
|
# Dev default = 32 zero bytes (NOT safe for production).
|
||||||
PII_ENCRYPTION_KEY: "${PII_ENCRYPTION_KEY:-}"
|
PII_ENCRYPTION_KEY: "${PII_ENCRYPTION_KEY:-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=}"
|
||||||
PII_NER_ENABLED: "true"
|
PII_NER_ENABLED: "true"
|
||||||
PII_NER_CONFIDENCE: "0.85"
|
PII_NER_CONFIDENCE: "0.65"
|
||||||
PII_TTL_SECONDS: "3600"
|
PII_TTL_SECONDS: "3600"
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
@ -221,8 +197,8 @@ services:
|
|||||||
- ./web:/app
|
- ./web:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
environment:
|
environment:
|
||||||
VITE_AUTH_MODE: "dev"
|
VITE_AUTH_MODE: "email"
|
||||||
VITE_KEYCLOAK_URL: "http://localhost:8080/realms/veylant"
|
VITE_PROXY_TARGET: "http://proxy:8090"
|
||||||
depends_on:
|
depends_on:
|
||||||
proxy:
|
proxy:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
|||||||
1
go.mod
1
go.mod
@ -44,6 +44,7 @@ require (
|
|||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/go-pdf/fpdf v0.9.0 // indirect
|
github.com/go-pdf/fpdf v0.9.0 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@ -70,6 +70,8 @@ github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiU
|
|||||||
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
|
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
|
||||||
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
|
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/veylant/ia-gateway/internal/apierror"
|
"github.com/veylant/ia-gateway/internal/apierror"
|
||||||
"github.com/veylant/ia-gateway/internal/auditlog"
|
"github.com/veylant/ia-gateway/internal/auditlog"
|
||||||
"github.com/veylant/ia-gateway/internal/circuitbreaker"
|
"github.com/veylant/ia-gateway/internal/circuitbreaker"
|
||||||
|
"github.com/veylant/ia-gateway/internal/crypto"
|
||||||
"github.com/veylant/ia-gateway/internal/flags"
|
"github.com/veylant/ia-gateway/internal/flags"
|
||||||
"github.com/veylant/ia-gateway/internal/middleware"
|
"github.com/veylant/ia-gateway/internal/middleware"
|
||||||
"github.com/veylant/ia-gateway/internal/ratelimit"
|
"github.com/veylant/ia-gateway/internal/ratelimit"
|
||||||
@ -38,6 +39,8 @@ type Handler struct {
|
|||||||
auditLogger auditlog.Logger // nil = logs/costs endpoints return 501
|
auditLogger auditlog.Logger // nil = logs/costs endpoints return 501
|
||||||
db *sql.DB // nil = users endpoints return 501
|
db *sql.DB // nil = users endpoints return 501
|
||||||
router ProviderRouter // nil = providers/status returns 501
|
router ProviderRouter // nil = providers/status returns 501
|
||||||
|
adapterRouter ProviderAdapterRouter // nil = provider CRUD hot-reload disabled
|
||||||
|
encryptor *crypto.Encryptor // nil = API keys stored in plaintext (dev only)
|
||||||
rateLimiter *ratelimit.Limiter // nil = rate-limits endpoints return 501
|
rateLimiter *ratelimit.Limiter // nil = rate-limits endpoints return 501
|
||||||
rlStore *ratelimit.Store // nil if db is nil
|
rlStore *ratelimit.Store // nil if db is nil
|
||||||
flagStore flags.FlagStore // nil = flags endpoints return 501
|
flagStore flags.FlagStore // nil = flags endpoints return 501
|
||||||
@ -65,6 +68,16 @@ func (h *Handler) WithDB(db *sql.DB) *Handler {
|
|||||||
// WithRouter adds provider router for circuit breaker status.
|
// WithRouter adds provider router for circuit breaker status.
|
||||||
func (h *Handler) WithRouter(r ProviderRouter) *Handler {
|
func (h *Handler) WithRouter(r ProviderRouter) *Handler {
|
||||||
h.router = r
|
h.router = r
|
||||||
|
// If the router also supports adapter hot-reload, wire it up.
|
||||||
|
if ar, ok := r.(ProviderAdapterRouter); ok {
|
||||||
|
h.adapterRouter = ar
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithEncryptor adds an AES-256-GCM encryptor for provider API key storage.
|
||||||
|
func (h *Handler) WithEncryptor(enc *crypto.Encryptor) *Handler {
|
||||||
|
h.encryptor = enc
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,6 +121,13 @@ func (h *Handler) Routes(r chi.Router) {
|
|||||||
// Provider circuit breaker status (E2-09 / E2-10).
|
// Provider circuit breaker status (E2-09 / E2-10).
|
||||||
r.Get("/providers/status", h.getProviderStatus)
|
r.Get("/providers/status", h.getProviderStatus)
|
||||||
|
|
||||||
|
// Provider config CRUD — stored in database, API keys encrypted at rest.
|
||||||
|
r.Get("/providers", h.listProviderConfigs)
|
||||||
|
r.Post("/providers", h.createProviderConfig)
|
||||||
|
r.Put("/providers/{id}", h.updateProviderConfig)
|
||||||
|
r.Delete("/providers/{id}", h.deleteProviderConfig)
|
||||||
|
r.Post("/providers/{id}/test", h.testProviderConfig)
|
||||||
|
|
||||||
// Rate limit configuration (E10-09).
|
// Rate limit configuration (E10-09).
|
||||||
r.Get("/rate-limits", h.listRateLimits)
|
r.Get("/rate-limits", h.listRateLimits)
|
||||||
r.Get("/rate-limits/{tenant_id}", h.getRateLimit)
|
r.Get("/rate-limits/{tenant_id}", h.getRateLimit)
|
||||||
|
|||||||
611
internal/admin/provider_configs.go
Normal file
611
internal/admin/provider_configs.go
Normal file
@ -0,0 +1,611 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/veylant/ia-gateway/internal/apierror"
|
||||||
|
"github.com/veylant/ia-gateway/internal/crypto"
|
||||||
|
"github.com/veylant/ia-gateway/internal/middleware"
|
||||||
|
"github.com/veylant/ia-gateway/internal/provider"
|
||||||
|
"github.com/veylant/ia-gateway/internal/provider/anthropic"
|
||||||
|
"github.com/veylant/ia-gateway/internal/provider/azure"
|
||||||
|
"github.com/veylant/ia-gateway/internal/provider/mistral"
|
||||||
|
"github.com/veylant/ia-gateway/internal/provider/ollama"
|
||||||
|
"github.com/veylant/ia-gateway/internal/provider/openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProviderAdapterRouter is the interface used by the admin handler to update
|
||||||
|
// adapters at runtime. Defined here to avoid import cycles with internal/router.
|
||||||
|
type ProviderAdapterRouter interface {
|
||||||
|
ProviderRouter
|
||||||
|
UpdateAdapter(name string, adapter provider.Adapter)
|
||||||
|
RemoveAdapter(name string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderConfig is the DB/JSON representation of a configured LLM provider.
|
||||||
|
type ProviderConfig struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
APIKeyMasked string `json:"api_key,omitempty"` // masked on read, plain on write
|
||||||
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
|
ResourceName string `json:"resource_name,omitempty"`
|
||||||
|
DeploymentID string `json:"deployment_id,omitempty"`
|
||||||
|
APIVersion string `json:"api_version,omitempty"`
|
||||||
|
TimeoutSec int `json:"timeout_sec"`
|
||||||
|
MaxConns int `json:"max_conns"`
|
||||||
|
ModelPatterns []string `json:"model_patterns"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type upsertProviderRequest struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
APIKey string `json:"api_key"` // plaintext, encrypted before storage
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
ResourceName string `json:"resource_name"`
|
||||||
|
DeploymentID string `json:"deployment_id"`
|
||||||
|
APIVersion string `json:"api_version"`
|
||||||
|
TimeoutSec int `json:"timeout_sec"`
|
||||||
|
MaxConns int `json:"max_conns"`
|
||||||
|
ModelPatterns []string `json:"model_patterns"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// providerStore wraps *sql.DB for provider_configs CRUD.
|
||||||
|
type providerStore struct {
|
||||||
|
db *sql.DB
|
||||||
|
encryptor *crypto.Encryptor
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProviderStore(db *sql.DB, enc *crypto.Encryptor, logger *zap.Logger) *providerStore {
|
||||||
|
return &providerStore{db: db, encryptor: enc, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *providerStore) list(ctx context.Context, tenantID string) ([]ProviderConfig, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT id, tenant_id, provider, display_name, api_key_enc, base_url,
|
||||||
|
resource_name, deployment_id, api_version, timeout_sec, max_conns,
|
||||||
|
model_patterns, is_active, created_at, updated_at
|
||||||
|
FROM provider_configs
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
ORDER BY created_at ASC`, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var configs []ProviderConfig
|
||||||
|
for rows.Next() {
|
||||||
|
var c ProviderConfig
|
||||||
|
var apiKeyEnc string
|
||||||
|
var patterns []string
|
||||||
|
if err := rows.Scan(&c.ID, &c.TenantID, &c.Provider, &c.DisplayName,
|
||||||
|
&apiKeyEnc, &c.BaseURL, &c.ResourceName, &c.DeploymentID, &c.APIVersion,
|
||||||
|
&c.TimeoutSec, &c.MaxConns, (*pqStringArray)(&patterns),
|
||||||
|
&c.IsActive, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.ModelPatterns = patterns
|
||||||
|
c.APIKeyMasked = maskAPIKey(apiKeyEnc, s.encryptor)
|
||||||
|
configs = append(configs, c)
|
||||||
|
}
|
||||||
|
return configs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *providerStore) get(ctx context.Context, id, tenantID string) (*ProviderConfig, string, error) {
|
||||||
|
var c ProviderConfig
|
||||||
|
var apiKeyEnc string
|
||||||
|
var patterns []string
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, tenant_id, provider, display_name, api_key_enc, base_url,
|
||||||
|
resource_name, deployment_id, api_version, timeout_sec, max_conns,
|
||||||
|
model_patterns, is_active, created_at, updated_at
|
||||||
|
FROM provider_configs WHERE id = $1 AND tenant_id = $2`, id, tenantID,
|
||||||
|
).Scan(&c.ID, &c.TenantID, &c.Provider, &c.DisplayName,
|
||||||
|
&apiKeyEnc, &c.BaseURL, &c.ResourceName, &c.DeploymentID, &c.APIVersion,
|
||||||
|
&c.TimeoutSec, &c.MaxConns, (*pqStringArray)(&patterns),
|
||||||
|
&c.IsActive, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
c.ModelPatterns = patterns
|
||||||
|
return &c, apiKeyEnc, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *providerStore) create(ctx context.Context, tenantID string, req upsertProviderRequest) (*ProviderConfig, error) {
|
||||||
|
apiKeyEnc := encryptKey(req.APIKey, s.encryptor)
|
||||||
|
isActive := true
|
||||||
|
if req.IsActive != nil {
|
||||||
|
isActive = *req.IsActive
|
||||||
|
}
|
||||||
|
timeoutSec := req.TimeoutSec
|
||||||
|
if timeoutSec == 0 {
|
||||||
|
timeoutSec = defaultTimeout(req.Provider)
|
||||||
|
}
|
||||||
|
maxConns := req.MaxConns
|
||||||
|
if maxConns == 0 {
|
||||||
|
maxConns = 100
|
||||||
|
}
|
||||||
|
apiVersion := req.APIVersion
|
||||||
|
if apiVersion == "" {
|
||||||
|
apiVersion = "2024-02-01"
|
||||||
|
}
|
||||||
|
|
||||||
|
var c ProviderConfig
|
||||||
|
var storedPatterns []string
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`INSERT INTO provider_configs
|
||||||
|
(tenant_id, provider, display_name, api_key_enc, base_url, resource_name,
|
||||||
|
deployment_id, api_version, timeout_sec, max_conns, model_patterns, is_active)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::text[],$12)
|
||||||
|
ON CONFLICT (tenant_id, provider) DO UPDATE SET
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
api_key_enc = CASE WHEN EXCLUDED.api_key_enc = '' THEN provider_configs.api_key_enc ELSE EXCLUDED.api_key_enc END,
|
||||||
|
base_url = EXCLUDED.base_url,
|
||||||
|
resource_name = EXCLUDED.resource_name,
|
||||||
|
deployment_id = EXCLUDED.deployment_id,
|
||||||
|
api_version = EXCLUDED.api_version,
|
||||||
|
timeout_sec = EXCLUDED.timeout_sec,
|
||||||
|
max_conns = EXCLUDED.max_conns,
|
||||||
|
model_patterns= EXCLUDED.model_patterns,
|
||||||
|
is_active = EXCLUDED.is_active,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING id, tenant_id, provider, display_name, api_key_enc, base_url,
|
||||||
|
resource_name, deployment_id, api_version, timeout_sec, max_conns,
|
||||||
|
model_patterns, is_active, created_at, updated_at`,
|
||||||
|
tenantID, req.Provider, displayName(req), apiKeyEnc, req.BaseURL, req.ResourceName,
|
||||||
|
req.DeploymentID, apiVersion, timeoutSec, maxConns,
|
||||||
|
modelPatternsLiteral(req.ModelPatterns), isActive,
|
||||||
|
).Scan(&c.ID, &c.TenantID, &c.Provider, &c.DisplayName,
|
||||||
|
&apiKeyEnc, &c.BaseURL, &c.ResourceName, &c.DeploymentID, &c.APIVersion,
|
||||||
|
&c.TimeoutSec, &c.MaxConns, (*pqStringArray)(&storedPatterns),
|
||||||
|
&c.IsActive, &c.CreatedAt, &c.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.ModelPatterns = storedPatterns
|
||||||
|
c.APIKeyMasked = maskAPIKey(apiKeyEnc, s.encryptor)
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *providerStore) delete(ctx context.Context, id, tenantID string) error {
|
||||||
|
res, err := s.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM provider_configs WHERE id = $1 AND tenant_id = $2`, id, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTTP handlers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) listProviderConfigs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := tenantFromCtx(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.db == nil {
|
||||||
|
apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ps := newProviderStore(h.db, h.encryptor, h.logger)
|
||||||
|
configs, err := ps.list(r.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
apierror.WriteError(w, apierror.NewUpstreamError("failed to list providers: "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if configs == nil {
|
||||||
|
configs = []ProviderConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich with circuit breaker status if available
|
||||||
|
type enriched struct {
|
||||||
|
ProviderConfig
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
Failures int `json:"failures,omitempty"`
|
||||||
|
}
|
||||||
|
statuses := map[string]string{}
|
||||||
|
failures := map[string]int{}
|
||||||
|
if h.router != nil {
|
||||||
|
for _, s := range h.router.ProviderStatuses() {
|
||||||
|
statuses[s.Provider] = s.State
|
||||||
|
failures[s.Provider] = s.Failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := make([]enriched, len(configs))
|
||||||
|
for i, c := range configs {
|
||||||
|
result[i] = enriched{
|
||||||
|
ProviderConfig: c,
|
||||||
|
State: statuses[c.Provider],
|
||||||
|
Failures: failures[c.Provider],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"data": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) createProviderConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := tenantFromCtx(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.db == nil {
|
||||||
|
apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req upsertProviderRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON: "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validProvider(req.Provider) {
|
||||||
|
apierror.WriteError(w, apierror.NewBadRequestError("provider must be one of: openai, anthropic, azure, mistral, ollama"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := newProviderStore(h.db, h.encryptor, h.logger)
|
||||||
|
cfg, err := ps.create(r.Context(), tenantID, req)
|
||||||
|
if err != nil {
|
||||||
|
apierror.WriteError(w, apierror.NewUpstreamError("failed to save provider: "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild and hot-reload the adapter
|
||||||
|
h.reloadAdapter(r.Context(), cfg, req.APIKey)
|
||||||
|
h.logger.Info("provider config saved", zap.String("provider", cfg.Provider), zap.String("tenant", tenantID))
|
||||||
|
writeJSON(w, http.StatusCreated, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) updateProviderConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := tenantFromCtx(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.db == nil {
|
||||||
|
apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
var req upsertProviderRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON: "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ps := newProviderStore(h.db, h.encryptor, h.logger)
|
||||||
|
// Fetch existing to get provider name if not supplied
|
||||||
|
existing, existingKeyEnc, err := ps.get(r.Context(), id, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
apierror.WriteError(w, apierror.NewUpstreamError(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
apierror.WriteError(w, &apierror.APIError{Type: "not_found_error", Message: "provider not found", HTTPStatus: http.StatusNotFound})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Provider == "" {
|
||||||
|
req.Provider = existing.Provider
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no new API key provided, keep the existing encrypted one by passing empty string
|
||||||
|
// (the upsert handles this via the CASE statement)
|
||||||
|
decryptedKey := req.APIKey
|
||||||
|
if decryptedKey == "" && existingKeyEnc != "" {
|
||||||
|
// Decrypt existing key to pass to adapter rebuild
|
||||||
|
if h.encryptor != nil {
|
||||||
|
if dec, decErr := h.encryptor.Decrypt(existingKeyEnc); decErr == nil {
|
||||||
|
decryptedKey = dec
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
decryptedKey = existingKeyEnc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := ps.create(r.Context(), tenantID, req) // upsert
|
||||||
|
if err != nil {
|
||||||
|
apierror.WriteError(w, apierror.NewUpstreamError("failed to update provider: "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.reloadAdapter(r.Context(), cfg, decryptedKey)
|
||||||
|
h.logger.Info("provider config updated", zap.String("provider", cfg.Provider), zap.String("id", id))
|
||||||
|
writeJSON(w, http.StatusOK, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteProviderConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := tenantFromCtx(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.db == nil {
|
||||||
|
apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
ps := newProviderStore(h.db, h.encryptor, h.logger)
|
||||||
|
existing, _, err := ps.get(r.Context(), id, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
apierror.WriteError(w, apierror.NewUpstreamError(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
apierror.WriteError(w, &apierror.APIError{Type: "not_found_error", Message: "provider not found", HTTPStatus: http.StatusNotFound})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ps.delete(r.Context(), id, tenantID); err != nil {
|
||||||
|
apierror.WriteError(w, apierror.NewUpstreamError("failed to delete provider: "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove adapter from router (config-based providers survive if they have an API key in env)
|
||||||
|
if h.adapterRouter != nil {
|
||||||
|
h.adapterRouter.RemoveAdapter(existing.Provider)
|
||||||
|
}
|
||||||
|
h.logger.Info("provider config deleted", zap.String("provider", existing.Provider))
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) testProviderConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID, ok := tenantFromCtx(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.db == nil {
|
||||||
|
apierror.WriteError(w, &apierror.APIError{Type: "not_implemented", Message: "database not configured", HTTPStatus: http.StatusNotImplemented})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
ps := newProviderStore(h.db, h.encryptor, h.logger)
|
||||||
|
existing, apiKeyEnc, err := ps.get(r.Context(), id, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
apierror.WriteError(w, apierror.NewUpstreamError(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
apierror.WriteError(w, &apierror.APIError{Type: "not_found_error", Message: "provider not found", HTTPStatus: http.StatusNotFound})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := ""
|
||||||
|
if h.encryptor != nil && apiKeyEnc != "" {
|
||||||
|
if dec, decErr := h.encryptor.Decrypt(apiKeyEnc); decErr == nil {
|
||||||
|
apiKey = dec
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
apiKey = apiKeyEnc
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter := buildAdapter(existing, apiKey)
|
||||||
|
if adapter == nil {
|
||||||
|
apierror.WriteError(w, apierror.NewBadRequestError("cannot build adapter for provider "+existing.Provider))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := adapter.HealthCheck(r.Context()); err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"healthy": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"healthy": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Adapter helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// reloadAdapter rebuilds the provider adapter from cfg and registers it on the router.
|
||||||
|
func (h *Handler) reloadAdapter(_ context.Context, cfg *ProviderConfig, apiKey string) {
|
||||||
|
if h.adapterRouter == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adapter := buildAdapter(cfg, apiKey)
|
||||||
|
if adapter != nil {
|
||||||
|
h.adapterRouter.UpdateAdapter(cfg.Provider, adapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildProviderAdapter constructs the correct provider.Adapter from a ProviderConfig.
|
||||||
|
// Exported so main.go can call it when loading provider configs from DB at startup.
|
||||||
|
func BuildProviderAdapter(cfg *ProviderConfig, apiKey string) provider.Adapter {
|
||||||
|
return buildAdapter(cfg, apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAdapter constructs the correct provider.Adapter from a ProviderConfig.
|
||||||
|
func buildAdapter(cfg *ProviderConfig, apiKey string) provider.Adapter {
|
||||||
|
switch cfg.Provider {
|
||||||
|
case "openai":
|
||||||
|
return openai.New(openai.Config{
|
||||||
|
APIKey: apiKey,
|
||||||
|
BaseURL: orDefault(cfg.BaseURL, "https://api.openai.com/v1"),
|
||||||
|
TimeoutSeconds: orDefaultInt(cfg.TimeoutSec, 30),
|
||||||
|
MaxConns: orDefaultInt(cfg.MaxConns, 100),
|
||||||
|
})
|
||||||
|
case "anthropic":
|
||||||
|
return anthropic.New(anthropic.Config{
|
||||||
|
APIKey: apiKey,
|
||||||
|
BaseURL: orDefault(cfg.BaseURL, "https://api.anthropic.com/v1"),
|
||||||
|
Version: "2023-06-01",
|
||||||
|
TimeoutSeconds: orDefaultInt(cfg.TimeoutSec, 30),
|
||||||
|
MaxConns: orDefaultInt(cfg.MaxConns, 100),
|
||||||
|
})
|
||||||
|
case "azure":
|
||||||
|
return azure.New(azure.Config{
|
||||||
|
APIKey: apiKey,
|
||||||
|
ResourceName: cfg.ResourceName,
|
||||||
|
DeploymentID: cfg.DeploymentID,
|
||||||
|
APIVersion: orDefault(cfg.APIVersion, "2024-02-01"),
|
||||||
|
TimeoutSeconds: orDefaultInt(cfg.TimeoutSec, 30),
|
||||||
|
MaxConns: orDefaultInt(cfg.MaxConns, 100),
|
||||||
|
})
|
||||||
|
case "mistral":
|
||||||
|
return mistral.New(mistral.Config{
|
||||||
|
APIKey: apiKey,
|
||||||
|
BaseURL: orDefault(cfg.BaseURL, "https://api.mistral.ai/v1"),
|
||||||
|
TimeoutSeconds: orDefaultInt(cfg.TimeoutSec, 30),
|
||||||
|
MaxConns: orDefaultInt(cfg.MaxConns, 100),
|
||||||
|
})
|
||||||
|
case "ollama":
|
||||||
|
return ollama.New(ollama.Config{
|
||||||
|
BaseURL: orDefault(cfg.BaseURL, "http://localhost:11434/v1"),
|
||||||
|
TimeoutSeconds: orDefaultInt(cfg.TimeoutSec, 120),
|
||||||
|
MaxConns: orDefaultInt(cfg.MaxConns, 10),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func validProvider(p string) bool {
|
||||||
|
switch p {
|
||||||
|
case "openai", "anthropic", "azure", "mistral", "ollama":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultTimeout(provider string) int {
|
||||||
|
if provider == "ollama" {
|
||||||
|
return 120
|
||||||
|
}
|
||||||
|
return 30
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayName(req upsertProviderRequest) string {
|
||||||
|
if req.DisplayName != "" {
|
||||||
|
return req.DisplayName
|
||||||
|
}
|
||||||
|
return strings.Title(req.Provider) //nolint:staticcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptKey(key string, enc *crypto.Encryptor) string {
|
||||||
|
if key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if enc == nil {
|
||||||
|
return key // dev mode: store plaintext
|
||||||
|
}
|
||||||
|
encrypted, err := enc.Encrypt(key)
|
||||||
|
if err != nil {
|
||||||
|
return key // fallback to plaintext on error
|
||||||
|
}
|
||||||
|
return encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
func maskAPIKey(apiKeyEnc string, enc *crypto.Encryptor) string {
|
||||||
|
if apiKeyEnc == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Decrypt to get the real key, then mask it
|
||||||
|
plaintext := apiKeyEnc
|
||||||
|
if enc != nil {
|
||||||
|
if dec, err := enc.Decrypt(apiKeyEnc); err == nil {
|
||||||
|
plaintext = dec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(plaintext) <= 8 {
|
||||||
|
return strings.Repeat("•", len(plaintext))
|
||||||
|
}
|
||||||
|
return plaintext[:8] + "••••••••"
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDefault(s, def string) string {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDefaultInt(v, def int) int {
|
||||||
|
if v == 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// pqStringArray is a simple []string scanner for PostgreSQL TEXT[] columns.
|
||||||
|
type pqStringArray []string
|
||||||
|
|
||||||
|
func (a *pqStringArray) Scan(src interface{}) error {
|
||||||
|
if src == nil {
|
||||||
|
*a = []string{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// PostgreSQL returns TEXT[] as "{val1,val2,...}" string
|
||||||
|
s, ok := src.(string)
|
||||||
|
if !ok {
|
||||||
|
if b, ok := src.([]byte); ok {
|
||||||
|
s = string(b)
|
||||||
|
} else {
|
||||||
|
*a = []string{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s = strings.TrimPrefix(s, "{")
|
||||||
|
s = strings.TrimSuffix(s, "}")
|
||||||
|
if s == "" {
|
||||||
|
*a = []string{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
result := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.Trim(p, `"`)
|
||||||
|
if p != "" {
|
||||||
|
result = append(result, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*a = result
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements driver.Valuer for pqStringArray (for INSERT).
|
||||||
|
func (a pqStringArray) Value() (interface{}, error) {
|
||||||
|
if len(a) == 0 {
|
||||||
|
return "{}", nil
|
||||||
|
}
|
||||||
|
parts := make([]string, len(a))
|
||||||
|
for i, v := range a {
|
||||||
|
parts[i] = `"` + strings.ReplaceAll(v, `"`, `\"`) + `"`
|
||||||
|
}
|
||||||
|
return "{" + strings.Join(parts, ",") + "}", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelPatternsLiteral converts a []string to a PostgreSQL array literal string
|
||||||
|
// (e.g. {"a","b"}) to be used with $N::text[] in queries.
|
||||||
|
// This avoids driver.Valuer issues with the pgx stdlib adapter.
|
||||||
|
func modelPatternsLiteral(patterns []string) string {
|
||||||
|
if len(patterns) == 0 {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
parts := make([]string, len(patterns))
|
||||||
|
for i, v := range patterns {
|
||||||
|
parts[i] = `"` + strings.ReplaceAll(v, `"`, `\"`) + `"`
|
||||||
|
}
|
||||||
|
return "{" + strings.Join(parts, ",") + "}"
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimsFromRequest is a local helper (avoids import of middleware in this file).
|
||||||
|
func claimsFromRequest(r *http.Request) *middleware.UserClaims {
|
||||||
|
claims, _ := middleware.ClaimsFromContext(r.Context())
|
||||||
|
return claims
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
"github.com/veylant/ia-gateway/internal/apierror"
|
"github.com/veylant/ia-gateway/internal/apierror"
|
||||||
"github.com/veylant/ia-gateway/internal/middleware"
|
"github.com/veylant/ia-gateway/internal/middleware"
|
||||||
@ -32,6 +33,7 @@ type createUserRequest struct {
|
|||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
IsActive *bool `json:"is_active"`
|
IsActive *bool `json:"is_active"`
|
||||||
|
Password string `json:"password"` // optional; bcrypt-hashed before storage
|
||||||
}
|
}
|
||||||
|
|
||||||
// userStore wraps a *sql.DB to perform user CRUD operations.
|
// userStore wraps a *sql.DB to perform user CRUD operations.
|
||||||
@ -77,27 +79,38 @@ func (s *userStore) get(id, tenantID string) (*User, error) {
|
|||||||
return &u, err
|
return &u, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userStore) create(u User) (*User, error) {
|
func (s *userStore) create(u User, passwordHash string) (*User, error) {
|
||||||
var created User
|
var created User
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
`INSERT INTO users (tenant_id, email, name, department, role, is_active)
|
`INSERT INTO users (tenant_id, email, name, department, role, is_active, password_hash)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6)
|
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||||
RETURNING id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at`,
|
RETURNING id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at`,
|
||||||
u.TenantID, u.Email, u.Name, u.Department, u.Role, u.IsActive,
|
u.TenantID, u.Email, u.Name, u.Department, u.Role, u.IsActive, passwordHash,
|
||||||
).Scan(&created.ID, &created.TenantID, &created.Email, &created.Name, &created.Department,
|
).Scan(&created.ID, &created.TenantID, &created.Email, &created.Name, &created.Department,
|
||||||
&created.Role, &created.IsActive, &created.CreatedAt, &created.UpdatedAt)
|
&created.Role, &created.IsActive, &created.CreatedAt, &created.UpdatedAt)
|
||||||
return &created, err
|
return &created, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *userStore) update(u User) (*User, error) {
|
func (s *userStore) update(u User, passwordHash string) (*User, error) {
|
||||||
var updated User
|
var updated User
|
||||||
err := s.db.QueryRow(
|
var err error
|
||||||
|
if passwordHash != "" {
|
||||||
|
err = s.db.QueryRow(
|
||||||
|
`UPDATE users SET email=$1, name=$2, department=$3, role=$4, is_active=$5, password_hash=$6, updated_at=NOW()
|
||||||
|
WHERE id=$7 AND tenant_id=$8
|
||||||
|
RETURNING id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at`,
|
||||||
|
u.Email, u.Name, u.Department, u.Role, u.IsActive, passwordHash, u.ID, u.TenantID,
|
||||||
|
).Scan(&updated.ID, &updated.TenantID, &updated.Email, &updated.Name, &updated.Department,
|
||||||
|
&updated.Role, &updated.IsActive, &updated.CreatedAt, &updated.UpdatedAt)
|
||||||
|
} else {
|
||||||
|
err = s.db.QueryRow(
|
||||||
`UPDATE users SET email=$1, name=$2, department=$3, role=$4, is_active=$5, updated_at=NOW()
|
`UPDATE users SET email=$1, name=$2, department=$3, role=$4, is_active=$5, updated_at=NOW()
|
||||||
WHERE id=$6 AND tenant_id=$7
|
WHERE id=$6 AND tenant_id=$7
|
||||||
RETURNING id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at`,
|
RETURNING id, tenant_id, email, name, COALESCE(department,''), role, is_active, created_at, updated_at`,
|
||||||
u.Email, u.Name, u.Department, u.Role, u.IsActive, u.ID, u.TenantID,
|
u.Email, u.Name, u.Department, u.Role, u.IsActive, u.ID, u.TenantID,
|
||||||
).Scan(&updated.ID, &updated.TenantID, &updated.Email, &updated.Name, &updated.Department,
|
).Scan(&updated.ID, &updated.TenantID, &updated.Email, &updated.Name, &updated.Department,
|
||||||
&updated.Role, &updated.IsActive, &updated.CreatedAt, &updated.UpdatedAt)
|
&updated.Role, &updated.IsActive, &updated.CreatedAt, &updated.UpdatedAt)
|
||||||
|
}
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@ -173,6 +186,16 @@ func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
isActive = *req.IsActive
|
isActive = *req.IsActive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
passwordHash := ""
|
||||||
|
if req.Password != "" {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
apierror.WriteError(w, apierror.NewUpstreamError("failed to hash password"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passwordHash = string(hash)
|
||||||
|
}
|
||||||
|
|
||||||
us := newUserStore(h.db, h.logger)
|
us := newUserStore(h.db, h.logger)
|
||||||
created, err := us.create(User{
|
created, err := us.create(User{
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
@ -181,7 +204,7 @@ func (h *Handler) createUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
Department: req.Department,
|
Department: req.Department,
|
||||||
Role: role,
|
Role: role,
|
||||||
IsActive: isActive,
|
IsActive: isActive,
|
||||||
})
|
}, passwordHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
apierror.WriteError(w, apierror.NewUpstreamError("failed to create user: "+err.Error()))
|
apierror.WriteError(w, apierror.NewUpstreamError("failed to create user: "+err.Error()))
|
||||||
return
|
return
|
||||||
@ -233,6 +256,16 @@ func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
isActive = *req.IsActive
|
isActive = *req.IsActive
|
||||||
}
|
}
|
||||||
|
|
||||||
|
passwordHash := ""
|
||||||
|
if req.Password != "" {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
apierror.WriteError(w, apierror.NewUpstreamError("failed to hash password"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
passwordHash = string(hash)
|
||||||
|
}
|
||||||
|
|
||||||
us := newUserStore(h.db, h.logger)
|
us := newUserStore(h.db, h.logger)
|
||||||
updated, err := us.update(User{
|
updated, err := us.update(User{
|
||||||
ID: id,
|
ID: id,
|
||||||
@ -242,7 +275,7 @@ func (h *Handler) updateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
Department: req.Department,
|
Department: req.Department,
|
||||||
Role: req.Role,
|
Role: req.Role,
|
||||||
IsActive: isActive,
|
IsActive: isActive,
|
||||||
})
|
}, passwordHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
apierror.WriteError(w, apierror.NewUpstreamError(err.Error()))
|
apierror.WriteError(w, apierror.NewUpstreamError(err.Error()))
|
||||||
return
|
return
|
||||||
|
|||||||
80
internal/auth/jwt.go
Normal file
80
internal/auth/jwt.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
// Package auth provides local JWT-based authentication replacing Keycloak OIDC.
|
||||||
|
// Tokens are signed with HMAC-SHA256 using a secret configured via VEYLANT_AUTH_JWT_SECRET.
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
"github.com/veylant/ia-gateway/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LocalJWTVerifier implements middleware.TokenVerifier using self-signed HS256 tokens.
|
||||||
|
type LocalJWTVerifier struct {
|
||||||
|
secret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLocalJWTVerifier creates a verifier that validates tokens signed with secret.
|
||||||
|
func NewLocalJWTVerifier(secret string) *LocalJWTVerifier {
|
||||||
|
return &LocalJWTVerifier{secret: []byte(secret)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// veylantClaims is the JWT payload structure for tokens we issue.
|
||||||
|
type veylantClaims struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
Department string `json:"department,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify implements middleware.TokenVerifier.
|
||||||
|
func (v *LocalJWTVerifier) Verify(_ context.Context, rawToken string) (*middleware.UserClaims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(rawToken, &veylantClaims{}, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||||
|
}
|
||||||
|
return v.secret, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("token verification failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*veylantClaims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, errors.New("invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &middleware.UserClaims{
|
||||||
|
UserID: claims.Subject,
|
||||||
|
TenantID: claims.TenantID,
|
||||||
|
Email: claims.Email,
|
||||||
|
Roles: claims.Roles,
|
||||||
|
Department: claims.Department,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken creates a signed JWT for the given claims.
|
||||||
|
func GenerateToken(claims *middleware.UserClaims, name, secret string, ttlHours int) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
jwtClaims := veylantClaims{
|
||||||
|
Email: claims.Email,
|
||||||
|
TenantID: claims.TenantID,
|
||||||
|
Roles: claims.Roles,
|
||||||
|
Department: claims.Department,
|
||||||
|
Name: name,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: claims.UserID,
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(ttlHours) * time.Hour)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwtClaims)
|
||||||
|
return token.SignedString([]byte(secret))
|
||||||
|
}
|
||||||
116
internal/auth/login.go
Normal file
116
internal/auth/login.go
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
"github.com/veylant/ia-gateway/internal/apierror"
|
||||||
|
"github.com/veylant/ia-gateway/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginHandler handles POST /v1/auth/login (public — no JWT required).
|
||||||
|
type LoginHandler struct {
|
||||||
|
db *sql.DB
|
||||||
|
jwtSecret string
|
||||||
|
jwtTTLHours int
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLoginHandler creates a LoginHandler.
|
||||||
|
func NewLoginHandler(db *sql.DB, jwtSecret string, jwtTTLHours int, logger *zap.Logger) *LoginHandler {
|
||||||
|
return &LoginHandler{db: db, jwtSecret: jwtSecret, jwtTTLHours: jwtTTLHours, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
User userInfo `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type userInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
Department string `json:"department,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP handles the login request.
|
||||||
|
func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.db == nil {
|
||||||
|
apierror.WriteError(w, &apierror.APIError{
|
||||||
|
Type: "not_implemented",
|
||||||
|
Message: "database not configured",
|
||||||
|
HTTPStatus: http.StatusNotImplemented,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req loginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
apierror.WriteError(w, apierror.NewBadRequestError("invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Email == "" || req.Password == "" {
|
||||||
|
apierror.WriteError(w, apierror.NewBadRequestError("email and password are required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up user by email (cross-tenant — each email is unique per tenant, but
|
||||||
|
// for V1 single-tenant we look globally).
|
||||||
|
var u userInfo
|
||||||
|
var passwordHash string
|
||||||
|
err := h.db.QueryRowContext(r.Context(),
|
||||||
|
`SELECT id, tenant_id, email, email, role, COALESCE(department,''), password_hash
|
||||||
|
FROM users
|
||||||
|
WHERE email = $1 AND is_active = TRUE
|
||||||
|
LIMIT 1`,
|
||||||
|
req.Email,
|
||||||
|
).Scan(&u.ID, &u.TenantID, &u.Email, &u.Name, &u.Role, &u.Department, &passwordHash)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// Use same error to avoid user enumeration
|
||||||
|
apierror.WriteError(w, apierror.NewAuthError("invalid credentials"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("login db query failed", zap.Error(err))
|
||||||
|
apierror.WriteError(w, apierror.NewUpstreamError("login failed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
|
||||||
|
apierror.WriteError(w, apierror.NewAuthError("invalid credentials"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := &middleware.UserClaims{
|
||||||
|
UserID: u.ID,
|
||||||
|
TenantID: u.TenantID,
|
||||||
|
Email: u.Email,
|
||||||
|
Roles: []string{u.Role},
|
||||||
|
Department: u.Department,
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := GenerateToken(claims, u.Name, h.jwtSecret, h.jwtTTLHours)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("token generation failed", zap.Error(err))
|
||||||
|
apierror.WriteError(w, apierror.NewUpstreamError("failed to generate token"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("user logged in", zap.String("email", u.Email), zap.String("tenant_id", u.TenantID))
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(loginResponse{Token: token, User: u})
|
||||||
|
}
|
||||||
@ -14,7 +14,7 @@ type Config struct {
|
|||||||
Server ServerConfig `mapstructure:"server"`
|
Server ServerConfig `mapstructure:"server"`
|
||||||
Database DatabaseConfig `mapstructure:"database"`
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
Redis RedisConfig `mapstructure:"redis"`
|
Redis RedisConfig `mapstructure:"redis"`
|
||||||
Keycloak KeycloakConfig `mapstructure:"keycloak"`
|
Auth AuthConfig `mapstructure:"auth"`
|
||||||
PII PIIConfig `mapstructure:"pii"`
|
PII PIIConfig `mapstructure:"pii"`
|
||||||
Log LogConfig `mapstructure:"log"`
|
Log LogConfig `mapstructure:"log"`
|
||||||
Providers ProvidersConfig `mapstructure:"providers"`
|
Providers ProvidersConfig `mapstructure:"providers"`
|
||||||
@ -145,10 +145,11 @@ type RedisConfig struct {
|
|||||||
URL string `mapstructure:"url"`
|
URL string `mapstructure:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KeycloakConfig struct {
|
// AuthConfig holds local JWT authentication settings.
|
||||||
BaseURL string `mapstructure:"base_url"`
|
// Override via VEYLANT_AUTH_JWT_SECRET and VEYLANT_AUTH_JWT_TTL_HOURS.
|
||||||
Realm string `mapstructure:"realm"`
|
type AuthConfig struct {
|
||||||
ClientID string `mapstructure:"client_id"`
|
JWTSecret string `mapstructure:"jwt_secret"`
|
||||||
|
JWTTTLHours int `mapstructure:"jwt_ttl_hours"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PIIConfig struct {
|
type PIIConfig struct {
|
||||||
@ -186,6 +187,8 @@ func Load() (*Config, error) {
|
|||||||
v.SetDefault("database.max_open_conns", 25)
|
v.SetDefault("database.max_open_conns", 25)
|
||||||
v.SetDefault("database.max_idle_conns", 5)
|
v.SetDefault("database.max_idle_conns", 5)
|
||||||
v.SetDefault("database.migrations_path", "migrations")
|
v.SetDefault("database.migrations_path", "migrations")
|
||||||
|
v.SetDefault("auth.jwt_secret", "change-me-in-production-use-VEYLANT_AUTH_JWT_SECRET")
|
||||||
|
v.SetDefault("auth.jwt_ttl_hours", 24)
|
||||||
v.SetDefault("pii.enabled", false)
|
v.SetDefault("pii.enabled", false)
|
||||||
v.SetDefault("pii.service_addr", "localhost:50051")
|
v.SetDefault("pii.service_addr", "localhost:50051")
|
||||||
v.SetDefault("pii.timeout_ms", 100)
|
v.SetDefault("pii.timeout_ms", 100)
|
||||||
|
|||||||
@ -101,7 +101,7 @@ func (c *Client) Detect(
|
|||||||
RequestId: requestID,
|
RequestId: requestID,
|
||||||
Options: &piiv1.PiiOptions{
|
Options: &piiv1.PiiOptions{
|
||||||
EnableNer: enableNER,
|
EnableNer: enableNER,
|
||||||
ConfidenceThreshold: 0.85,
|
ConfidenceThreshold: 0.65,
|
||||||
ZeroRetention: zeroRetention,
|
ZeroRetention: zeroRetention,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,7 +2,11 @@ package pii
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
@ -30,14 +34,14 @@ type AnalyzeResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AnalyzeHandler wraps a pii.Client as an HTTP handler for the playground.
|
// AnalyzeHandler wraps a pii.Client as an HTTP handler for the playground.
|
||||||
// It is safe to call when client is nil: returns the original text unchanged.
|
// It is safe to call when client is nil: falls back to regex detection.
|
||||||
type AnalyzeHandler struct {
|
type AnalyzeHandler struct {
|
||||||
client *Client
|
client *Client
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAnalyzeHandler creates a new AnalyzeHandler.
|
// NewAnalyzeHandler creates a new AnalyzeHandler.
|
||||||
// client may be nil (PII service disabled) — the handler degrades gracefully.
|
// client may be nil (PII service disabled) — the handler falls back to regex.
|
||||||
func NewAnalyzeHandler(client *Client, logger *zap.Logger) *AnalyzeHandler {
|
func NewAnalyzeHandler(client *Client, logger *zap.Logger) *AnalyzeHandler {
|
||||||
return &AnalyzeHandler{client: client, logger: logger}
|
return &AnalyzeHandler{client: client, logger: logger}
|
||||||
}
|
}
|
||||||
@ -55,30 +59,14 @@ func (h *AnalyzeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// PII service disabled — return text unchanged.
|
// Attempt real PII detection if service is available.
|
||||||
if h.client == nil {
|
// NOTE: when fail_open=true, Detect() returns (result, nil) even on RPC
|
||||||
w.Header().Set("Content-Type", "application/json")
|
// failure, but sets result.Entities to nil to signal the degraded path.
|
||||||
w.WriteHeader(http.StatusOK)
|
// A real successful response always has a non-nil Entities slice.
|
||||||
_ = json.NewEncoder(w).Encode(AnalyzeResponse{
|
if h.client != nil {
|
||||||
Anonymized: req.Text,
|
|
||||||
Entities: []AnalyzeEntity{},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := h.client.Detect(r.Context(), req.Text, "playground", "playground-analyze", true, false)
|
resp, err := h.client.Detect(r.Context(), req.Text, "playground", "playground-analyze", true, false)
|
||||||
if err != nil {
|
if err == nil && resp.Entities != nil {
|
||||||
h.logger.Warn("PII analyze failed", zap.Error(err))
|
// Real PII service response (may be empty if no PII detected).
|
||||||
// Fail-open: return text unchanged rather than erroring.
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_ = json.NewEncoder(w).Encode(AnalyzeResponse{
|
|
||||||
Anonymized: req.Text,
|
|
||||||
Entities: []AnalyzeEntity{},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
entities := make([]AnalyzeEntity, 0, len(resp.Entities))
|
entities := make([]AnalyzeEntity, 0, len(resp.Entities))
|
||||||
for _, e := range resp.Entities {
|
for _, e := range resp.Entities {
|
||||||
entities = append(entities, AnalyzeEntity{
|
entities = append(entities, AnalyzeEntity{
|
||||||
@ -89,11 +77,108 @@ func (h *AnalyzeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
Layer: e.DetectionLayer,
|
Layer: e.DetectionLayer,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_ = json.NewEncoder(w).Encode(AnalyzeResponse{
|
_ = json.NewEncoder(w).Encode(AnalyzeResponse{
|
||||||
Anonymized: resp.AnonymizedText,
|
Anonymized: resp.AnonymizedText,
|
||||||
Entities: entities,
|
Entities: entities,
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("PII service error — falling back to regex detection", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
h.logger.Debug("PII service unavailable (fail-open) — falling back to regex detection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: local regex detection so the playground stays useful when the
|
||||||
|
// PII sidecar is not running (dev mode, demo environments).
|
||||||
|
result := regexDetect(req.Text)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Regex-based local detection ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Package-level compiled regexes (compiled once at startup).
|
||||||
|
var (
|
||||||
|
rePiiIBAN = regexp.MustCompile(`(?i)FR\d{2}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{4}[\s]?\d{3}`)
|
||||||
|
rePiiEmail = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`)
|
||||||
|
rePiiPhone = regexp.MustCompile(`(?:\+33|0033|0)[\s]?[1-9](?:[\s.\-]?\d{2}){4}`)
|
||||||
|
rePiiCC = regexp.MustCompile(`(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2})(?:[ \-]?\d{4}){3}`)
|
||||||
|
rePiiSSN = regexp.MustCompile(`\b[12][\s]?\d{2}[\s]?\d{2}[\s]?\d{2}[\s]?\d{3}[\s]?\d{3}[\s]?\d{2}\b`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type regexPattern struct {
|
||||||
|
re *regexp.Regexp
|
||||||
|
typ string
|
||||||
|
}
|
||||||
|
|
||||||
|
var piiPatterns = []regexPattern{
|
||||||
|
{rePiiIBAN, "IBAN_CODE"},
|
||||||
|
{rePiiEmail, "EMAIL_ADDRESS"},
|
||||||
|
{rePiiPhone, "PHONE_NUMBER"},
|
||||||
|
{rePiiCC, "CREDIT_CARD"},
|
||||||
|
{rePiiSSN, "FR_SSN"},
|
||||||
|
}
|
||||||
|
|
||||||
|
type rawMatch struct {
|
||||||
|
typ string
|
||||||
|
start int
|
||||||
|
end int
|
||||||
|
}
|
||||||
|
|
||||||
|
// regexDetect runs a set of compiled regexes over text and returns matched
|
||||||
|
// entities with their byte offsets, plus an anonymized version of the text.
|
||||||
|
func regexDetect(text string) AnalyzeResponse {
|
||||||
|
var raw []rawMatch
|
||||||
|
for _, p := range piiPatterns {
|
||||||
|
for _, loc := range p.re.FindAllStringIndex(text, -1) {
|
||||||
|
raw = append(raw, rawMatch{typ: p.typ, start: loc[0], end: loc[1]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort by start position.
|
||||||
|
sort.Slice(raw, func(i, j int) bool { return raw[i].start < raw[j].start })
|
||||||
|
|
||||||
|
// Remove overlapping matches (keep the first / longest-starting one).
|
||||||
|
filtered := raw[:0]
|
||||||
|
cursor := 0
|
||||||
|
for _, m := range raw {
|
||||||
|
if m.start >= cursor {
|
||||||
|
filtered = append(filtered, m)
|
||||||
|
cursor = m.end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return AnalyzeResponse{Anonymized: text, Entities: []AnalyzeEntity{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build anonymized text and entity list simultaneously.
|
||||||
|
var sb strings.Builder
|
||||||
|
counters := map[string]int{}
|
||||||
|
entities := make([]AnalyzeEntity, 0, len(filtered))
|
||||||
|
cursor = 0
|
||||||
|
|
||||||
|
for _, m := range filtered {
|
||||||
|
sb.WriteString(text[cursor:m.start])
|
||||||
|
counters[m.typ]++
|
||||||
|
sb.WriteString(fmt.Sprintf("[%s_%d]", m.typ, counters[m.typ]))
|
||||||
|
entities = append(entities, AnalyzeEntity{
|
||||||
|
Type: m.typ,
|
||||||
|
Start: m.start,
|
||||||
|
End: m.end,
|
||||||
|
Confidence: 0.95,
|
||||||
|
Layer: "regex-local",
|
||||||
|
})
|
||||||
|
cursor = m.end
|
||||||
|
}
|
||||||
|
sb.WriteString(text[cursor:])
|
||||||
|
|
||||||
|
return AnalyzeResponse{
|
||||||
|
Anonymized: sb.String(),
|
||||||
|
Entities: entities,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,16 +25,36 @@ type modelRule struct {
|
|||||||
|
|
||||||
// defaultModelRules maps model name prefixes to provider names.
|
// defaultModelRules maps model name prefixes to provider names.
|
||||||
// Rules are evaluated in order; first match wins.
|
// Rules are evaluated in order; first match wins.
|
||||||
|
// Ollama rules cover common self-hosted model families available via `ollama pull`.
|
||||||
var defaultModelRules = []modelRule{
|
var defaultModelRules = []modelRule{
|
||||||
|
// Cloud providers (matched before generic local names)
|
||||||
{"gpt-", "openai"},
|
{"gpt-", "openai"},
|
||||||
{"o1-", "openai"},
|
{"o1-", "openai"},
|
||||||
{"o3-", "openai"},
|
{"o3-", "openai"},
|
||||||
{"claude-", "anthropic"},
|
{"claude-", "anthropic"},
|
||||||
{"mistral-", "mistral"},
|
{"mistral-", "mistral"}, // Mistral AI cloud (mistral-small, mistral-large…)
|
||||||
{"mixtral-", "mistral"},
|
{"mixtral-", "mistral"},
|
||||||
{"llama", "ollama"},
|
// Ollama / self-hosted models
|
||||||
{"phi", "ollama"},
|
{"llama", "ollama"}, // llama3, llama3.2, llama2…
|
||||||
{"qwen", "ollama"},
|
{"phi", "ollama"}, // phi3, phi4, phi3.5…
|
||||||
|
{"qwen", "ollama"}, // qwen2, qwen2.5, qwen-72b…
|
||||||
|
{"gemma", "ollama"}, // gemma2, gemma3 (Google)
|
||||||
|
{"deepseek", "ollama"}, // deepseek-r1, deepseek-coder…
|
||||||
|
{"llava", "ollama"}, // llava, llava-phi3 (multimodal)
|
||||||
|
{"tinyllama", "ollama"},
|
||||||
|
{"codellama", "ollama"},
|
||||||
|
{"yi-", "ollama"}, // yi-coder, yi-34b…
|
||||||
|
{"vicuna", "ollama"},
|
||||||
|
{"starcoder", "ollama"}, // starcoder2…
|
||||||
|
{"solar", "ollama"},
|
||||||
|
{"falcon", "ollama"},
|
||||||
|
{"orca", "ollama"}, // orca-mini…
|
||||||
|
{"nous", "ollama"}, // nous-hermes…
|
||||||
|
{"hermes", "ollama"},
|
||||||
|
{"wizard", "ollama"},
|
||||||
|
{"stable", "ollama"}, // stablelm…
|
||||||
|
{"command-r", "ollama"}, // Cohere Command-R via Ollama
|
||||||
|
{"neural", "ollama"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router implements provider.Adapter. It selects the correct upstream adapter
|
// Router implements provider.Adapter. It selects the correct upstream adapter
|
||||||
@ -92,6 +112,25 @@ func (r *Router) WithFlagStore(fs flags.FlagStore) *Router {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAdapter registers or replaces the adapter for the given provider name.
|
||||||
|
// This allows dynamic reloading from the database without a proxy restart.
|
||||||
|
func (r *Router) UpdateAdapter(name string, adapter provider.Adapter) {
|
||||||
|
r.adapters[name] = adapter
|
||||||
|
r.logger.Info("provider adapter updated", zap.String("provider", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAdapter removes the adapter for the given provider name.
|
||||||
|
func (r *Router) RemoveAdapter(name string) {
|
||||||
|
delete(r.adapters, name)
|
||||||
|
r.logger.Info("provider adapter removed", zap.String("provider", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddModelRules prepends extra prefix rules (from DB provider configs).
|
||||||
|
// Called after loading provider_configs so custom patterns take precedence.
|
||||||
|
func (r *Router) AddModelRules(rules []modelRule) {
|
||||||
|
r.modelRules = append(rules, r.modelRules...)
|
||||||
|
}
|
||||||
|
|
||||||
// ProviderStatuses returns circuit breaker status for all known providers.
|
// ProviderStatuses returns circuit breaker status for all known providers.
|
||||||
// Returns an empty slice when no circuit breaker is configured.
|
// Returns an empty slice when no circuit breaker is configured.
|
||||||
func (r *Router) ProviderStatuses() []circuitbreaker.Status {
|
func (r *Router) ProviderStatuses() []circuitbreaker.Status {
|
||||||
|
|||||||
2
migrations/000010_local_auth.down.sql
Normal file
2
migrations/000010_local_auth.down.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- Rollback 000010
|
||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS password_hash;
|
||||||
10
migrations/000010_local_auth.up.sql
Normal file
10
migrations/000010_local_auth.up.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- Migration 000010: Local email/password authentication
|
||||||
|
-- Adds password_hash column to users table (replaces Keycloak external_id auth).
|
||||||
|
-- Seeds dev users with bcrypt hashes.
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Update seed dev admin user with bcrypt("admin123")
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = '$2a$10$P6IygH9MpDmKrrACsnYeCeAsTEgVUrFWbHksa4KS7FHSCBcExDYXy'
|
||||||
|
WHERE email = 'admin@veylant.dev';
|
||||||
2
migrations/000011_provider_configs.down.sql
Normal file
2
migrations/000011_provider_configs.down.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- Rollback 000011
|
||||||
|
DROP TABLE IF EXISTS provider_configs;
|
||||||
29
migrations/000011_provider_configs.up.sql
Normal file
29
migrations/000011_provider_configs.up.sql
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
-- Migration 000011: Provider configurations stored in database
|
||||||
|
-- Replaces environment-variable-only provider config with DB-driven CRUD.
|
||||||
|
-- API keys are stored encrypted (AES-256-GCM via crypto.Encryptor).
|
||||||
|
|
||||||
|
CREATE TABLE provider_configs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL
|
||||||
|
CHECK (provider IN ('openai','anthropic','azure','mistral','ollama')),
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
api_key_enc TEXT NOT NULL DEFAULT '', -- AES-256-GCM encrypted, empty for Ollama
|
||||||
|
base_url TEXT NOT NULL DEFAULT '',
|
||||||
|
resource_name TEXT NOT NULL DEFAULT '', -- Azure only
|
||||||
|
deployment_id TEXT NOT NULL DEFAULT '', -- Azure only
|
||||||
|
api_version TEXT NOT NULL DEFAULT '2024-02-01',
|
||||||
|
timeout_sec INT NOT NULL DEFAULT 30,
|
||||||
|
max_conns INT NOT NULL DEFAULT 100,
|
||||||
|
model_patterns TEXT[] NOT NULL DEFAULT '{}', -- extra model name prefixes routed here
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (tenant_id, provider)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_provider_configs_tenant ON provider_configs(tenant_id, is_active);
|
||||||
|
|
||||||
|
CREATE TRIGGER provider_configs_updated_at
|
||||||
|
BEFORE UPDATE ON provider_configs
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
1
migrations/000012_users_add_name.down.sql
Normal file
1
migrations/000012_users_add_name.down.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS name;
|
||||||
6
migrations/000012_users_add_name.up.sql
Normal file
6
migrations/000012_users_add_name.up.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
-- Migration 000012: Add name column to users table.
|
||||||
|
-- Migration 000001 created users without a name column; migration 000006 used
|
||||||
|
-- CREATE TABLE IF NOT EXISTS which was a no-op since the table already existed.
|
||||||
|
-- This migration adds the missing column retroactively.
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
|
||||||
@ -2,6 +2,9 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# gen/ must be in PYTHONPATH so grpc stubs can resolve 'from pii.v1 import ...'
|
||||||
|
ENV PYTHONPATH=/app/gen
|
||||||
|
|
||||||
# Install system dependencies for spaCy compilation
|
# Install system dependencies for spaCy compilation
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
gcc \
|
gcc \
|
||||||
|
|||||||
@ -12,7 +12,7 @@ from typing import TYPE_CHECKING
|
|||||||
from layers.regex_layer import DetectedEntity
|
from layers.regex_layer import DetectedEntity
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from presidio_analyzer import AnalyzerEngine
|
from presidio_analyzer import AnalyzerEngine # noqa: F401 — type hint only
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -95,15 +95,25 @@ class NERLayer:
|
|||||||
|
|
||||||
def _build_analyzer(self) -> "AnalyzerEngine":
|
def _build_analyzer(self) -> "AnalyzerEngine":
|
||||||
from presidio_analyzer import AnalyzerEngine
|
from presidio_analyzer import AnalyzerEngine
|
||||||
from presidio_analyzer.nlp_engine import NlpEngineProvider
|
from presidio_analyzer.nlp_engine import NerModelConfiguration, SpacyNlpEngine
|
||||||
|
|
||||||
configuration = {
|
# The default NerModelConfiguration incorrectly places 'ORG' and 'ORGANIZATION'
|
||||||
"nlp_engine_name": "spacy",
|
# in labels_to_ignore, which prevents ORGANIZATION entities from being returned.
|
||||||
"models": [
|
# We build a custom configuration that keeps those labels.
|
||||||
|
default_cfg = NerModelConfiguration()
|
||||||
|
custom_labels_to_ignore = default_cfg.labels_to_ignore - {"ORG", "ORGANIZATION"}
|
||||||
|
|
||||||
|
ner_config = NerModelConfiguration(
|
||||||
|
model_to_presidio_entity_mapping=default_cfg.model_to_presidio_entity_mapping,
|
||||||
|
low_score_entity_names=default_cfg.low_score_entity_names,
|
||||||
|
labels_to_ignore=custom_labels_to_ignore,
|
||||||
|
)
|
||||||
|
|
||||||
|
nlp_engine = SpacyNlpEngine(
|
||||||
|
models=[
|
||||||
{"lang_code": "fr", "model_name": self._fr_model},
|
{"lang_code": "fr", "model_name": self._fr_model},
|
||||||
{"lang_code": "en", "model_name": self._en_model},
|
{"lang_code": "en", "model_name": self._en_model},
|
||||||
],
|
],
|
||||||
}
|
ner_model_configuration=ner_config,
|
||||||
provider = NlpEngineProvider(nlp_configuration=configuration)
|
)
|
||||||
nlp_engine = provider.create_engine()
|
|
||||||
return AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=["fr", "en"])
|
return AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=["fr", "en"])
|
||||||
|
|||||||
@ -67,9 +67,9 @@ _RE_EMAIL = re.compile(
|
|||||||
r"\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b"
|
r"\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b"
|
||||||
)
|
)
|
||||||
|
|
||||||
# French mobile: 06/07, with optional +33 or 0033 prefix, various separators
|
# French phone (mobile 06/07 + landlines 01-05, 08, 09), optional +33/0033 prefix
|
||||||
_RE_PHONE_FR = re.compile(
|
_RE_PHONE_FR = re.compile(
|
||||||
r"(?:(?:\+|00)33\s?|0)[67](?:[\s.\-]?\d{2}){4}"
|
r"(?:(?:\+|00)33\s?|0)[1-9](?:[\s.\-]?\d{2}){4}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# International phone: + followed by 1-3 digit country code + 6-12 digits
|
# International phone: + followed by 1-3 digit country code + 6-12 digits
|
||||||
@ -77,10 +77,10 @@ _RE_PHONE_INTL = re.compile(
|
|||||||
r"(?<!\d)\+[1-9]\d{0,2}[\s.\-]?\d{4,14}(?!\d)"
|
r"(?<!\d)\+[1-9]\d{0,2}[\s.\-]?\d{4,14}(?!\d)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# French SSN (NIR): 13 digits + 2-digit key, with optional separators
|
# French SSN (NIR): 13 digits + 2-digit key, with optional spaces/hyphens between groups
|
||||||
# Format: [12] YY MM DEP COM NNN CC
|
# Format: [12] YY MM DEP COM NNN CC — e.g. "1 78 07 75 115 423 45" or "1780775115 42345"
|
||||||
_RE_FR_SSN = re.compile(
|
_RE_FR_SSN = re.compile(
|
||||||
r"\b([12]\d{2}(?:0[1-9]|1[0-2]|20)\d{2}\d{3}\d{3}\d{2})\b"
|
r"\b([12][\s\-]?\d{2}[\s\-]?(?:0[1-9]|1[0-2]|20)[\s\-]?\d{2}[\s\-]?\d{3}[\s\-]?\d{3}[\s\-]?\d{2})\b"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Credit card: 16 digits in groups of 4 (Visa, Mastercard, etc.)
|
# Credit card: 16 digits in groups of 4 (Visa, Mastercard, etc.)
|
||||||
@ -117,13 +117,16 @@ class RegexLayer:
|
|||||||
results = []
|
results = []
|
||||||
for m in _RE_IBAN.finditer(text):
|
for m in _RE_IBAN.finditer(text):
|
||||||
value = m.group(1)
|
value = m.group(1)
|
||||||
if _iban_valid(value):
|
# Accept IBANs with invalid MOD-97 checksum at lower confidence (useful for
|
||||||
|
# test/demo data where checksums may not be real).
|
||||||
|
confidence = 1.0 if _iban_valid(value) else 0.75
|
||||||
results.append(
|
results.append(
|
||||||
DetectedEntity(
|
DetectedEntity(
|
||||||
entity_type="IBAN",
|
entity_type="IBAN",
|
||||||
original_value=value,
|
original_value=value,
|
||||||
start=m.start(1),
|
start=m.start(1),
|
||||||
end=m.end(1),
|
end=m.end(1),
|
||||||
|
confidence=confidence,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
@ -186,13 +189,16 @@ class RegexLayer:
|
|||||||
results = []
|
results = []
|
||||||
for m in _RE_CREDIT_CARD.finditer(text):
|
for m in _RE_CREDIT_CARD.finditer(text):
|
||||||
digits_only = re.sub(r"[\s\-]", "", m.group(1))
|
digits_only = re.sub(r"[\s\-]", "", m.group(1))
|
||||||
if _luhn_valid(digits_only):
|
# Accept cards that fail Luhn at lower confidence (test/demo card numbers
|
||||||
|
# often don't have a valid Luhn checksum).
|
||||||
|
confidence = 1.0 if _luhn_valid(digits_only) else 0.75
|
||||||
results.append(
|
results.append(
|
||||||
DetectedEntity(
|
DetectedEntity(
|
||||||
entity_type="CREDIT_CARD",
|
entity_type="CREDIT_CARD",
|
||||||
original_value=m.group(1),
|
original_value=m.group(1),
|
||||||
start=m.start(1),
|
start=m.start(1),
|
||||||
end=m.end(1),
|
end=m.end(1),
|
||||||
|
confidence=confidence,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|||||||
@ -2,10 +2,11 @@
|
|||||||
fastapi==0.115.6
|
fastapi==0.115.6
|
||||||
uvicorn[standard]==0.32.1
|
uvicorn[standard]==0.32.1
|
||||||
|
|
||||||
# gRPC
|
# gRPC — versions must match the buf-generated stubs in gen/pii/v1/
|
||||||
grpcio==1.68.1
|
grpcio==1.78.1
|
||||||
grpcio-tools==1.68.1
|
grpcio-tools==1.78.1
|
||||||
grpcio-health-checking==1.68.1
|
grpcio-health-checking==1.78.1
|
||||||
|
protobuf==6.31.1
|
||||||
|
|
||||||
# PII detection (Sprint 3)
|
# PII detection (Sprint 3)
|
||||||
presidio-analyzer==2.2.356
|
presidio-analyzer==2.2.356
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
import type { ProviderStatus } from "@/types/api";
|
import type { CreateProviderRequest, ProviderConfig, ProviderStatus } from "@/types/api";
|
||||||
|
|
||||||
|
// ─── Circuit breaker status (legacy, still used by overview) ──────────────────
|
||||||
|
|
||||||
export function useProviderStatuses() {
|
export function useProviderStatuses() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@ -9,3 +11,58 @@ export function useProviderStatuses() {
|
|||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Provider config CRUD ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useProviderConfigs() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["providers", "configs"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await apiFetch<{ data: ProviderConfig[] }>("/v1/admin/providers");
|
||||||
|
return res.data ?? [];
|
||||||
|
},
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateProvider() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: CreateProviderRequest) =>
|
||||||
|
apiFetch<ProviderConfig>("/v1/admin/providers", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["providers"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateProvider() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: CreateProviderRequest }) =>
|
||||||
|
apiFetch<ProviderConfig>(`/v1/admin/providers/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["providers"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteProvider() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiFetch<void>(`/v1/admin/providers/${id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["providers"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTestProvider() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiFetch<{ healthy: boolean; error?: string }>(`/v1/admin/providers/${id}/test`, {
|
||||||
|
method: "POST",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -2,6 +2,21 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
import type { User } from "@/types/api";
|
import type { User } from "@/types/api";
|
||||||
|
|
||||||
|
export type UserPayload = {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
department: string;
|
||||||
|
role: string;
|
||||||
|
is_active: boolean;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportResult = {
|
||||||
|
success: number;
|
||||||
|
failed: number;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export function useListUsers() {
|
export function useListUsers() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["users"],
|
queryKey: ["users"],
|
||||||
@ -12,7 +27,7 @@ export function useListUsers() {
|
|||||||
export function useCreateUser() {
|
export function useCreateUser() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: Omit<User, "id" | "tenant_id" | "created_at" | "updated_at">) =>
|
mutationFn: (body: UserPayload) =>
|
||||||
apiFetch<User>("/v1/admin/users", { method: "POST", body: JSON.stringify(body) }),
|
apiFetch<User>("/v1/admin/users", { method: "POST", body: JSON.stringify(body) }),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||||
});
|
});
|
||||||
@ -21,7 +36,7 @@ export function useCreateUser() {
|
|||||||
export function useUpdateUser() {
|
export function useUpdateUser() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, ...body }: Partial<User> & { id: string }) =>
|
mutationFn: ({ id, ...body }: UserPayload & { id: string }) =>
|
||||||
apiFetch<User>(`/v1/admin/users/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
apiFetch<User>(`/v1/admin/users/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||||
});
|
});
|
||||||
@ -35,3 +50,23 @@ export function useDeleteUser() {
|
|||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useImportUsers() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (users: UserPayload[]): Promise<ImportResult> => {
|
||||||
|
const result: ImportResult = { success: 0, failed: 0, errors: [] };
|
||||||
|
for (const user of users) {
|
||||||
|
try {
|
||||||
|
await apiFetch<User>("/v1/admin/users", { method: "POST", body: JSON.stringify(user) });
|
||||||
|
result.success++;
|
||||||
|
} catch {
|
||||||
|
result.failed++;
|
||||||
|
result.errors.push(user.email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["users"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -1,9 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
|
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||||
import { AuthProvider as OidcAuthProvider, useAuth as useOidcAuth } from "react-oidc-context";
|
|
||||||
|
|
||||||
const AUTH_MODE = import.meta.env.VITE_AUTH_MODE ?? "dev";
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ─── Shared user type ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string;
|
id: string;
|
||||||
@ -13,140 +10,129 @@ export interface AuthUser {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Dev mode auth ────────────────────────────────────────────────────────────
|
interface AuthContextValue {
|
||||||
|
|
||||||
interface DevAuthContextValue {
|
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
login: () => void;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DevAuthContext = createContext<DevAuthContextValue | null>(null);
|
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const DEV_USER: AuthUser = {
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
id: "dev-user",
|
|
||||||
email: "dev@veylant.local",
|
|
||||||
name: "Dev Admin",
|
|
||||||
roles: ["admin"],
|
|
||||||
token: "dev-token",
|
|
||||||
};
|
|
||||||
|
|
||||||
function DevAuthProvider({ children }: { children: React.ReactNode }) {
|
const TOKEN_KEY = "veylant_token";
|
||||||
|
|
||||||
|
// Decode JWT payload (no verification — just read claims for UI).
|
||||||
|
function decodeJWT(token: string): Record<string, unknown> | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
const payload = atob(parts[1].replace(/-/g, "+").replace(/_/g, "/"));
|
||||||
|
return JSON.parse(payload) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function userFromToken(token: string): AuthUser | null {
|
||||||
|
const payload = decodeJWT(token);
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
const exp = payload["exp"] as number | undefined;
|
||||||
|
if (exp && exp * 1000 < Date.now()) {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = Array.isArray(payload["roles"])
|
||||||
|
? (payload["roles"] as string[])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: (payload["sub"] as string) ?? "",
|
||||||
|
email: (payload["email"] as string) ?? "",
|
||||||
|
name: (payload["name"] as string) ?? (payload["email"] as string) ?? "",
|
||||||
|
roles,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<AuthUser | null>(() => {
|
const [user, setUser] = useState<AuthUser | null>(() => {
|
||||||
return sessionStorage.getItem("dev-authed") ? DEV_USER : null;
|
const stored = localStorage.getItem(TOKEN_KEY);
|
||||||
|
return stored ? userFromToken(stored) : null;
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/v1/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const login = useCallback(() => {
|
if (!res.ok) {
|
||||||
sessionStorage.setItem("dev-authed", "1");
|
const body = await res.json().catch(() => ({}));
|
||||||
setUser(DEV_USER);
|
const msg = (body as { error?: { message?: string } }).error?.message ?? "Identifiants invalides";
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await res.json() as { token: string };
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
const u = userFromToken(token);
|
||||||
|
setUser(u);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
sessionStorage.removeItem("dev-authed");
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Auto-logout when token expires
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const payload = decodeJWT(user.token);
|
||||||
|
if (!payload) return;
|
||||||
|
const exp = payload["exp"] as number | undefined;
|
||||||
|
if (!exp) return;
|
||||||
|
const msUntilExpiry = exp * 1000 - Date.now();
|
||||||
|
if (msUntilExpiry <= 0) {
|
||||||
|
logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(logout, msUntilExpiry);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [user, logout]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DevAuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{ user, isLoading: false, isAuthenticated: !!user, login, logout }}
|
value={{ user, isLoading, isAuthenticated: !!user, login, logout }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DevAuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── OIDC mode adapter ────────────────────────────────────────────────────────
|
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface UnifiedAuthContextValue {
|
export function useAuth(): AuthContextValue {
|
||||||
user: AuthUser | null;
|
const ctx = useContext(AuthContext);
|
||||||
isLoading: boolean;
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
login: () => void;
|
|
||||||
logout: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UnifiedAuthContext = createContext<UnifiedAuthContextValue | null>(null);
|
|
||||||
|
|
||||||
function OidcAdapter({ children }: { children: React.ReactNode }) {
|
|
||||||
const auth = useOidcAuth();
|
|
||||||
|
|
||||||
const user: AuthUser | null = auth.user
|
|
||||||
? {
|
|
||||||
id: auth.user.profile.sub,
|
|
||||||
email: auth.user.profile.email ?? "",
|
|
||||||
name: auth.user.profile.name ?? auth.user.profile.email ?? "",
|
|
||||||
roles: (auth.user.profile as Record<string, unknown>)["realm_access"]
|
|
||||||
? ((auth.user.profile as Record<string, unknown>)["realm_access"] as { roles: string[] }).roles
|
|
||||||
: [],
|
|
||||||
token: auth.user.access_token,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnifiedAuthContext.Provider
|
|
||||||
value={{
|
|
||||||
user,
|
|
||||||
isLoading: auth.isLoading,
|
|
||||||
isAuthenticated: auth.isAuthenticated,
|
|
||||||
login: () => void auth.signinRedirect(),
|
|
||||||
logout: () => void auth.signoutRedirect(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</UnifiedAuthContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Public hook ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function useAuth(): UnifiedAuthContextValue {
|
|
||||||
if (AUTH_MODE === "dev") {
|
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
||||||
const ctx = useContext(DevAuthContext);
|
|
||||||
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
||||||
const ctx = useContext(UnifiedAuthContext);
|
|
||||||
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Root AuthProvider ────────────────────────────────────────────────────────
|
// ─── Token accessor (for API client outside React tree) ───────────────────────
|
||||||
|
|
||||||
const oidcConfig = {
|
|
||||||
authority: import.meta.env.VITE_KEYCLOAK_URL ?? "http://localhost:8080/realms/veylant",
|
|
||||||
client_id: "veylant-dashboard",
|
|
||||||
redirect_uri: `${window.location.origin}/callback`,
|
|
||||||
scope: "openid profile email",
|
|
||||||
onSigninCallback: () => {
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
if (AUTH_MODE === "dev") {
|
|
||||||
return <DevAuthProvider>{children}</DevAuthProvider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OidcAuthProvider {...oidcConfig}>
|
|
||||||
<OidcAdapter>
|
|
||||||
<UnifiedAuthContext.Consumer>
|
|
||||||
{(ctx) =>
|
|
||||||
ctx ? (
|
|
||||||
<UnifiedAuthContext.Provider value={ctx}>{children}</UnifiedAuthContext.Provider>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
</UnifiedAuthContext.Consumer>
|
|
||||||
</OidcAdapter>
|
|
||||||
</OidcAuthProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token accessor for API client (outside React tree)
|
|
||||||
let _getToken: (() => string | null) | null = null;
|
let _getToken: (() => string | null) | null = null;
|
||||||
|
|
||||||
export function setTokenAccessor(fn: () => string | null) {
|
export function setTokenAccessor(fn: () => string | null) {
|
||||||
@ -154,10 +140,14 @@ export function setTokenAccessor(fn: () => string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
return _getToken ? _getToken() : null;
|
// Try module-level accessor first, then localStorage directly
|
||||||
|
if (_getToken) return _getToken();
|
||||||
|
const stored = localStorage.getItem(TOKEN_KEY);
|
||||||
|
if (!stored) return null;
|
||||||
|
const u = userFromToken(stored);
|
||||||
|
return u?.token ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hook to register token accessor in context
|
|
||||||
export function useRegisterTokenAccessor() {
|
export function useRegisterTokenAccessor() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -1,14 +1,32 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import type { PiiEntity } from "@/types/api";
|
import type { PiiEntity } from "@/types/api";
|
||||||
|
|
||||||
const ENTITY_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
const ENTITY_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
// IBAN
|
||||||
IBAN_CODE: { bg: "bg-red-100", text: "text-red-800", label: "IBAN" },
|
IBAN_CODE: { bg: "bg-red-100", text: "text-red-800", label: "IBAN" },
|
||||||
|
IBAN: { bg: "bg-red-100", text: "text-red-800", label: "IBAN" },
|
||||||
|
// Credit card
|
||||||
CREDIT_CARD: { bg: "bg-red-100", text: "text-red-800", label: "CB" },
|
CREDIT_CARD: { bg: "bg-red-100", text: "text-red-800", label: "CB" },
|
||||||
|
// Email
|
||||||
|
EMAIL_ADDRESS: { bg: "bg-orange-100", text: "text-orange-800", label: "Email" },
|
||||||
EMAIL: { bg: "bg-orange-100", text: "text-orange-800", label: "Email" },
|
EMAIL: { bg: "bg-orange-100", text: "text-orange-800", label: "Email" },
|
||||||
|
// Phone
|
||||||
PHONE_NUMBER: { bg: "bg-purple-100", text: "text-purple-800", label: "Tél." },
|
PHONE_NUMBER: { bg: "bg-purple-100", text: "text-purple-800", label: "Tél." },
|
||||||
|
PHONE_FR: { bg: "bg-purple-100", text: "text-purple-800", label: "Tél." },
|
||||||
|
PHONE_INTL: { bg: "bg-purple-100", text: "text-purple-800", label: "Tél. Intl." },
|
||||||
|
// Person
|
||||||
PERSON: { bg: "bg-blue-100", text: "text-blue-800", label: "Personne" },
|
PERSON: { bg: "bg-blue-100", text: "text-blue-800", label: "Personne" },
|
||||||
|
PER: { bg: "bg-blue-100", text: "text-blue-800", label: "Personne" },
|
||||||
|
// SSN
|
||||||
FR_SSN: { bg: "bg-rose-100", text: "text-rose-800", label: "INSEE" },
|
FR_SSN: { bg: "bg-rose-100", text: "text-rose-800", label: "INSEE" },
|
||||||
|
FR_NIF: { bg: "bg-rose-100", text: "text-rose-800", label: "INSEE" },
|
||||||
|
// Location
|
||||||
LOCATION: { bg: "bg-green-100", text: "text-green-800", label: "Lieu" },
|
LOCATION: { bg: "bg-green-100", text: "text-green-800", label: "Lieu" },
|
||||||
|
LOC: { bg: "bg-green-100", text: "text-green-800", label: "Lieu" },
|
||||||
|
GPE: { bg: "bg-green-100", text: "text-green-800", label: "Lieu" },
|
||||||
|
// Organisation
|
||||||
ORGANIZATION: { bg: "bg-teal-100", text: "text-teal-800", label: "Org." },
|
ORGANIZATION: { bg: "bg-teal-100", text: "text-teal-800", label: "Org." },
|
||||||
|
ORG: { bg: "bg-teal-100", text: "text-teal-800", label: "Org." },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_COLOR = { bg: "bg-yellow-100", text: "text-yellow-800", label: "PII" };
|
const DEFAULT_COLOR = { bg: "bg-yellow-100", text: "text-yellow-800", label: "PII" };
|
||||||
@ -24,34 +42,46 @@ export function PiiHighlight({ text, entities, className }: Props) {
|
|||||||
return <span className={className}>{text}</span>;
|
return <span className={className}>{text}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by start position
|
// Sort by start position and remove overlapping spans.
|
||||||
const sorted = [...entities].sort((a, b) => a.start - b.start);
|
const sorted = [...entities]
|
||||||
|
.sort((a, b) => a.start - b.start)
|
||||||
|
.reduce<PiiEntity[]>((acc, e) => {
|
||||||
|
if (acc.length === 0 || e.start >= acc[acc.length - 1].end) {
|
||||||
|
acc.push(e);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const parts: React.ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
||||||
for (const entity of sorted) {
|
for (const entity of sorted) {
|
||||||
if (entity.start > cursor) {
|
// Guard against out-of-bounds offsets from the API.
|
||||||
parts.push(
|
const start = Math.max(0, Math.min(entity.start, text.length));
|
||||||
<span key={`text-${cursor}`}>{text.slice(cursor, entity.start)}</span>
|
const end = Math.max(start, Math.min(entity.end, text.length));
|
||||||
);
|
|
||||||
|
if (start > cursor) {
|
||||||
|
parts.push(<span key={`t-${cursor}`}>{text.slice(cursor, start)}</span>);
|
||||||
}
|
}
|
||||||
|
if (start < end) {
|
||||||
const color = ENTITY_COLORS[entity.type] ?? DEFAULT_COLOR;
|
const color = ENTITY_COLORS[entity.type] ?? DEFAULT_COLOR;
|
||||||
|
const layerNote = entity.layer === "regex" ? " (détection locale)" : "";
|
||||||
parts.push(
|
parts.push(
|
||||||
<mark
|
<mark
|
||||||
key={`entity-${entity.start}`}
|
key={`e-${start}`}
|
||||||
className={`${color.bg} ${color.text} rounded px-0.5 py-px text-xs font-medium cursor-help`}
|
className={`${color.bg} ${color.text} rounded px-0.5 py-px text-xs font-medium cursor-help`}
|
||||||
title={`${entity.type} — confiance: ${Math.round(entity.confidence * 100)}%`}
|
title={`${entity.type} — confiance: ${Math.round(entity.confidence * 100)}%${layerNote}`}
|
||||||
>
|
>
|
||||||
{text.slice(entity.start, entity.end)}
|
{text.slice(start, end)}
|
||||||
<sup className="ml-0.5 text-[9px] opacity-70">{color.label}</sup>
|
<sup className="ml-0.5 text-[9px] opacity-70">{color.label}</sup>
|
||||||
</mark>
|
</mark>
|
||||||
);
|
);
|
||||||
cursor = entity.end;
|
}
|
||||||
|
cursor = end;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursor < text.length) {
|
if (cursor < text.length) {
|
||||||
parts.push(<span key={`text-end`}>{text.slice(cursor)}</span>);
|
parts.push(<span key="t-end">{text.slice(cursor)}</span>);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <span className={className}>{parts}</span>;
|
return <span className={className}>{parts}</span>;
|
||||||
|
|||||||
137
web/src/components/UserForm.tsx
Normal file
137
web/src/components/UserForm.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import type { User } from "@/types/api";
|
||||||
|
import type { UserPayload } from "@/api/users";
|
||||||
|
|
||||||
|
const ROLES = ["admin", "manager", "user", "auditor"] as const;
|
||||||
|
|
||||||
|
const userSchema = z.object({
|
||||||
|
email: z.string().email("Email invalide"),
|
||||||
|
name: z.string().min(1, "Le nom est requis"),
|
||||||
|
department: z.string(),
|
||||||
|
role: z.enum(ROLES),
|
||||||
|
is_active: z.boolean(),
|
||||||
|
password: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type UserFormData = z.infer<typeof userSchema>;
|
||||||
|
|
||||||
|
interface UserFormProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
user?: User;
|
||||||
|
onSubmit: (payload: UserPayload) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserForm({ open, onOpenChange, user, onSubmit, isPending }: UserFormProps) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<UserFormData>({
|
||||||
|
resolver: zodResolver(userSchema),
|
||||||
|
defaultValues: { email: "", name: "", department: "", role: "user", is_active: true, password: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
reset(
|
||||||
|
user
|
||||||
|
? { email: user.email, name: user.name, department: user.department, role: user.role as typeof ROLES[number], is_active: user.is_active, password: "" }
|
||||||
|
: { email: "", name: "", department: "", role: "user", is_active: true, password: "" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [user, open, reset]);
|
||||||
|
|
||||||
|
const isActive = watch("is_active");
|
||||||
|
const role = watch("role");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{user ? "Modifier l'utilisateur" : "Nouvel utilisateur"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 py-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Email *</Label>
|
||||||
|
<Input {...register("email")} type="email" placeholder="prenom.nom@entreprise.com" />
|
||||||
|
{errors.email && <p className="text-xs text-destructive">{errors.email.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Nom complet *</Label>
|
||||||
|
<Input {...register("name")} placeholder="Prénom Nom" />
|
||||||
|
{errors.name && <p className="text-xs text-destructive">{errors.name.message}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Département</Label>
|
||||||
|
<Input {...register("department")} placeholder="ex: finance, engineering, hr" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Rôle</Label>
|
||||||
|
<Select value={role} onValueChange={(v) => setValue("role", v as typeof ROLES[number])}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<SelectItem key={r} value={r} className="capitalize">{r}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>
|
||||||
|
{user ? "Nouveau mot de passe (laisser vide pour ne pas changer)" : "Mot de passe (optionnel)"}
|
||||||
|
</Label>
|
||||||
|
<Input {...register("password")} type="password" placeholder="••••••••" autoComplete="new-password" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<Switch checked={isActive} onCheckedChange={(v) => setValue("is_active", v)} />
|
||||||
|
<Label className="cursor-pointer" onClick={() => setValue("is_active", !isActive)}>
|
||||||
|
Compte actif
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isPending}>
|
||||||
|
{isPending ? "Enregistrement..." : user ? "Mettre à jour" : "Créer"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,23 +1,38 @@
|
|||||||
import { useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import { Shield, LogIn } from "lucide-react";
|
import { Shield, LogIn, Eye, EyeOff } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { useAuth } from "@/auth/AuthProvider";
|
import { useAuth } from "@/auth/AuthProvider";
|
||||||
|
|
||||||
const AUTH_MODE = import.meta.env.VITE_AUTH_MODE ?? "dev";
|
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { login, isAuthenticated, isLoading } = useAuth();
|
const { login, isAuthenticated, isLoading } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? "/dashboard";
|
const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? "/dashboard";
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
navigate(from, { replace: true });
|
navigate(from, { replace: true });
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, navigate, from]);
|
}, [isAuthenticated, navigate, from]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Erreur de connexion");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
<div className="w-full max-w-sm space-y-8 px-4">
|
<div className="w-full max-w-sm space-y-8 px-4">
|
||||||
@ -34,36 +49,65 @@ export function LoginPage() {
|
|||||||
<div className="rounded-xl border bg-card p-8 shadow-sm space-y-6">
|
<div className="rounded-xl border bg-card p-8 shadow-sm space-y-6">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h2 className="text-lg font-semibold">Connexion</h2>
|
<h2 className="text-lg font-semibold">Connexion</h2>
|
||||||
{AUTH_MODE === "dev" ? (
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Mode développement — authentification simulée
|
Entrez vos identifiants pour accéder au dashboard
|
||||||
</p>
|
</p>
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Connectez-vous via votre compte Keycloak
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
className="w-full"
|
<div className="space-y-1">
|
||||||
size="lg"
|
<Label htmlFor="email">Email</Label>
|
||||||
onClick={login}
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="admin@veylant.dev"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
required
|
||||||
<LogIn className="h-4 w-4 mr-2" />
|
/>
|
||||||
{AUTH_MODE === "dev" ? "Se connecter (mode dev)" : "Se connecter avec Keycloak"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{AUTH_MODE === "dev" && (
|
|
||||||
<div className="rounded-lg bg-muted p-3 text-xs text-muted-foreground space-y-1">
|
|
||||||
<p className="font-medium">Utilisateurs de test disponibles :</p>
|
|
||||||
<p>admin@veylant.dev / admin123</p>
|
|
||||||
<p>manager@veylant.dev / manager123</p>
|
|
||||||
<p>user@veylant.dev / user123</p>
|
|
||||||
<p>auditor@veylant.dev / auditor123</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="password">Mot de passe</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
required
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive text-center">{error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" size="lg" disabled={isLoading}>
|
||||||
|
<LogIn className="h-4 w-4 mr-2" />
|
||||||
|
{isLoading ? "Connexion…" : "Se connecter"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-muted p-3 text-xs text-muted-foreground space-y-1">
|
||||||
|
<p className="font-medium">Compte de test :</p>
|
||||||
|
<p>admin@veylant.dev / admin123</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import { Send, FlaskConical, ShieldAlert } from "lucide-react";
|
import { Send, FlaskConical, ShieldAlert, AlertCircle } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@ -26,6 +26,18 @@ const MODELS = [
|
|||||||
|
|
||||||
const DEBOUNCE_MS = 600;
|
const DEBOUNCE_MS = 600;
|
||||||
|
|
||||||
|
// Legend mapping — covers both Python PII service types and Go regex-local fallback types.
|
||||||
|
const ENTITY_LEGEND: Record<string, string> = {
|
||||||
|
IBAN_CODE: "bg-red-100 text-red-800", IBAN: "bg-red-100 text-red-800",
|
||||||
|
CREDIT_CARD: "bg-red-100 text-red-800",
|
||||||
|
EMAIL_ADDRESS: "bg-orange-100 text-orange-800", EMAIL: "bg-orange-100 text-orange-800",
|
||||||
|
PHONE_NUMBER: "bg-purple-100 text-purple-800", PHONE_FR: "bg-purple-100 text-purple-800", PHONE_INTL: "bg-purple-100 text-purple-800",
|
||||||
|
PERSON: "bg-blue-100 text-blue-800", PER: "bg-blue-100 text-blue-800",
|
||||||
|
FR_SSN: "bg-rose-100 text-rose-800", FR_NIF: "bg-rose-100 text-rose-800",
|
||||||
|
LOCATION: "bg-green-100 text-green-800", LOC: "bg-green-100 text-green-800", GPE: "bg-green-100 text-green-800",
|
||||||
|
ORGANIZATION: "bg-teal-100 text-teal-800", ORG: "bg-teal-100 text-teal-800",
|
||||||
|
};
|
||||||
|
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
@ -36,37 +48,48 @@ export function PlaygroundPage() {
|
|||||||
const [model, setModel] = useState(MODELS[0].value);
|
const [model, setModel] = useState(MODELS[0].value);
|
||||||
const [entities, setEntities] = useState<PiiEntity[]>([]);
|
const [entities, setEntities] = useState<PiiEntity[]>([]);
|
||||||
const [anonymized, setAnonymized] = useState("");
|
const [anonymized, setAnonymized] = useState("");
|
||||||
|
const [piiLayer, setPiiLayer] = useState<"service" | "regex" | "none">("none");
|
||||||
const [aiResponse, setAiResponse] = useState("");
|
const [aiResponse, setAiResponse] = useState("");
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// ── PII mutation ──────────────────────────────────────────────────────────
|
||||||
|
// useMutation returns a new object every render; store it in a ref so that
|
||||||
|
// handleTextChange (empty deps) always calls the latest version without
|
||||||
|
// being recreated on every render (which would cancel the debounce).
|
||||||
const piiMutation = usePiiAnalyze();
|
const piiMutation = usePiiAnalyze();
|
||||||
|
const piiMutationRef = useRef(piiMutation);
|
||||||
|
piiMutationRef.current = piiMutation;
|
||||||
|
|
||||||
// Debounced PII analysis on text change
|
const handleTextChange = useCallback((value: string) => {
|
||||||
const handleTextChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
setText(value);
|
setText(value);
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
if (!value.trim()) {
|
if (!value.trim()) {
|
||||||
setEntities([]);
|
setEntities([]);
|
||||||
setAnonymized("");
|
setAnonymized("");
|
||||||
|
setPiiLayer("none");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
debounceRef.current = setTimeout(() => {
|
debounceRef.current = setTimeout(() => {
|
||||||
piiMutation.mutate(value, {
|
piiMutationRef.current.mutate(value, {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setEntities(result.entities);
|
setEntities(result.entities);
|
||||||
setAnonymized(result.anonymized);
|
setAnonymized(result.anonymized);
|
||||||
|
const layer = result.entities.some((e) => e.layer === "regex-local")
|
||||||
|
? "regex"
|
||||||
|
: result.entities.length > 0
|
||||||
|
? "service"
|
||||||
|
: "none";
|
||||||
|
setPiiLayer(layer);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setEntities([]);
|
setEntities([]);
|
||||||
setAnonymized(value);
|
setAnonymized(value);
|
||||||
|
setPiiLayer("none");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, DEBOUNCE_MS);
|
}, DEBOUNCE_MS);
|
||||||
},
|
}, []); // stable — uses ref internally
|
||||||
[piiMutation]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -74,24 +97,20 @@ export function PlaygroundPage() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── LLM send ─────────────────────────────────────────────────────────────
|
||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!text.trim() || isSending) return;
|
if (!text.trim() || isSending) return;
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
setAiResponse("");
|
setAiResponse("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messages: ChatMessage[] = [{ role: "user", content: text }];
|
const messages: ChatMessage[] = [{ role: "user", content: text }];
|
||||||
const response = await apiFetch<{ choices: { message: { content: string } }[] }>(
|
const response = await apiFetch<{ choices: { message: { content: string } }[] }>(
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
{
|
{ method: "POST", body: JSON.stringify({ model, messages, stream: false }) }
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ model, messages, stream: false }),
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
const content = response.choices?.[0]?.message?.content ?? "";
|
setAiResponse(response.choices?.[0]?.message?.content ?? "");
|
||||||
setAiResponse(content);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setAiResponse(`Erreur: ${err instanceof Error ? err.message : "Requête échouée"}`);
|
setAiResponse(`Erreur : ${err instanceof Error ? err.message : "Requête échouée"}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
}
|
}
|
||||||
@ -99,9 +118,11 @@ export function PlaygroundPage() {
|
|||||||
|
|
||||||
const piiCount = entities.length;
|
const piiCount = entities.length;
|
||||||
const hasText = text.trim().length > 0;
|
const hasText = text.trim().length > 0;
|
||||||
|
const uniqueTypes = [...new Set(entities.map((e) => e.type))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
@ -135,22 +156,29 @@ export function PlaygroundPage() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
{/* Left: Input */}
|
{/* Left: Input */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between min-h-[24px]">
|
||||||
<label className="text-sm font-medium">Votre prompt</label>
|
<label className="text-sm font-medium">Votre prompt</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{piiMutation.isPending && (
|
{piiMutation.isPending && (
|
||||||
<span className="text-xs text-muted-foreground animate-pulse">Analyse PII…</span>
|
<span className="text-xs text-muted-foreground animate-pulse">
|
||||||
|
Analyse PII…
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{piiCount > 0 && (
|
{piiCount > 0 && (
|
||||||
<Badge variant="warning" className="gap-1">
|
<Badge
|
||||||
|
className="gap-1 bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-100"
|
||||||
|
>
|
||||||
<ShieldAlert className="h-3 w-3" />
|
<ShieldAlert className="h-3 w-3" />
|
||||||
{piiCount} donnée{piiCount > 1 ? "s" : ""} sensible{piiCount > 1 ? "s" : ""}
|
{piiCount} donnée{piiCount > 1 ? "s" : ""} sensible
|
||||||
|
{piiCount > 1 ? "s" : ""}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => handleTextChange(e.target.value)}
|
onChange={(e) => handleTextChange(e.target.value)}
|
||||||
placeholder="Tapez votre prompt ici… Ex: Mon IBAN est FR7630006000011234567890189 et je m'appelle Jean Dupont."
|
placeholder="Tapez votre prompt ici… Ex : Mon IBAN est FR7630006000011234567890189 et je m'appelle Jean Dupont."
|
||||||
className="min-h-[200px] font-mono text-sm resize-y"
|
className="min-h-[200px] font-mono text-sm resize-y"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -159,8 +187,19 @@ export function PlaygroundPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* PII highlight panel */}
|
{/* PII highlight panel */}
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
<label className="text-sm font-medium">Analyse PII (temps réel)</label>
|
<label className="text-sm font-medium">Analyse PII (temps réel)</label>
|
||||||
<div className="mt-1 min-h-[80px] rounded-md border bg-muted/30 p-3 text-sm leading-relaxed whitespace-pre-wrap">
|
{piiLayer === "regex" && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-amber-600">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
Détection locale (service PII non disponible)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{piiLayer === "service" && (
|
||||||
|
<span className="text-xs text-green-600">Service PII actif</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-h-[200px] rounded-md border bg-muted/30 p-3 text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
{hasText ? (
|
{hasText ? (
|
||||||
<PiiHighlight text={text} entities={entities} />
|
<PiiHighlight text={text} entities={entities} />
|
||||||
) : (
|
) : (
|
||||||
@ -207,26 +246,16 @@ export function PlaygroundPage() {
|
|||||||
{piiCount > 0 && (
|
{piiCount > 0 && (
|
||||||
<div className="flex flex-wrap gap-2 pt-2 border-t">
|
<div className="flex flex-wrap gap-2 pt-2 border-t">
|
||||||
<span className="text-xs text-muted-foreground">Légende :</span>
|
<span className="text-xs text-muted-foreground">Légende :</span>
|
||||||
{[...new Set(entities.map((e) => e.type))].map((type) => {
|
{uniqueTypes.map((type) => (
|
||||||
const colors: Record<string, string> = {
|
|
||||||
IBAN_CODE: "bg-red-100 text-red-800",
|
|
||||||
CREDIT_CARD: "bg-red-100 text-red-800",
|
|
||||||
EMAIL: "bg-orange-100 text-orange-800",
|
|
||||||
PHONE_NUMBER: "bg-purple-100 text-purple-800",
|
|
||||||
PERSON: "bg-blue-100 text-blue-800",
|
|
||||||
FR_SSN: "bg-rose-100 text-rose-800",
|
|
||||||
LOCATION: "bg-green-100 text-green-800",
|
|
||||||
ORGANIZATION: "bg-teal-100 text-teal-800",
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<span
|
<span
|
||||||
key={type}
|
key={type}
|
||||||
className={`text-xs px-2 py-0.5 rounded font-medium ${colors[type] ?? "bg-yellow-100 text-yellow-800"}`}
|
className={`text-xs px-2 py-0.5 rounded font-medium ${
|
||||||
|
ENTITY_LEGEND[type] ?? "bg-yellow-100 text-yellow-800"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{type}
|
{type}
|
||||||
</span>
|
</span>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,158 +1,133 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ChevronRight, Check, Plug } from "lucide-react";
|
import {
|
||||||
|
ChevronRight, Check, Plus, Trash2, Pencil, Zap, ZapOff, RefreshCw, Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
AlertDialog, AlertDialogAction, AlertDialogCancel,
|
||||||
|
AlertDialogContent, AlertDialogDescription, AlertDialogFooter,
|
||||||
|
AlertDialogHeader, AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
useProviderConfigs, useCreateProvider, useUpdateProvider,
|
||||||
|
useDeleteProvider, useTestProvider,
|
||||||
|
} from "@/api/providers";
|
||||||
|
import type { CreateProviderRequest, ProviderConfig } from "@/types/api";
|
||||||
|
|
||||||
|
// ─── Provider metadata ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PROVIDER_INFO: Record<string, {
|
||||||
|
label: string; description: string; needsApiKey: boolean;
|
||||||
|
needsResource: boolean; needsBaseUrl: boolean;
|
||||||
|
}> = {
|
||||||
|
openai: { label: "OpenAI", description: "GPT-4o, GPT-4o-mini, o1…", needsApiKey: true, needsResource: false, needsBaseUrl: false },
|
||||||
|
anthropic: { label: "Anthropic", description: "Claude 3.5 Sonnet, Claude 3 Opus…", needsApiKey: true, needsResource: false, needsBaseUrl: false },
|
||||||
|
azure: { label: "Azure OpenAI", description: "Modèles OpenAI hébergés sur Azure", needsApiKey: true, needsResource: true, needsBaseUrl: false },
|
||||||
|
mistral: { label: "Mistral AI", description: "Mistral Small, Mistral Large…", needsApiKey: true, needsResource: false, needsBaseUrl: false },
|
||||||
|
ollama: { label: "Ollama", description: "Modèles locaux auto-hébergés", needsApiKey: false, needsResource: false, needsBaseUrl: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Form schemas ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const providerSchema = z.object({
|
||||||
|
provider: z.enum(["openai", "anthropic", "azure", "mistral", "ollama"]),
|
||||||
|
display_name: z.string().optional(),
|
||||||
|
api_key: z.string().optional(),
|
||||||
|
base_url: z.string().optional(),
|
||||||
|
resource_name: z.string().optional(),
|
||||||
|
deployment_id: z.string().optional(),
|
||||||
|
api_version: z.string().optional(),
|
||||||
|
model_patterns: z.string().optional(), // comma-separated
|
||||||
|
});
|
||||||
|
|
||||||
|
type ProviderFormValues = z.infer<typeof providerSchema>;
|
||||||
|
|
||||||
const STEPS = ["Fournisseur", "Connexion", "Confirmation"];
|
const STEPS = ["Fournisseur", "Connexion", "Confirmation"];
|
||||||
|
|
||||||
const step1Schema = z.object({
|
// ─── Wizard ───────────────────────────────────────────────────────────────────
|
||||||
provider: z.enum(["openai", "anthropic", "azure", "mistral", "ollama"]),
|
|
||||||
});
|
|
||||||
const step2Schema = z.object({
|
|
||||||
api_key: z.string().min(1, "La clé API est requise"),
|
|
||||||
resource_name: z.string().optional(),
|
|
||||||
deployment_id: z.string().optional(),
|
|
||||||
base_url: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type Step1 = z.infer<typeof step1Schema>;
|
interface WizardProps {
|
||||||
type Step2 = z.infer<typeof step2Schema>;
|
editTarget?: ProviderConfig;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
const PROVIDER_INFO: Record<
|
function ProviderWizard({ editTarget, onClose }: WizardProps) {
|
||||||
string,
|
const [step, setStep] = useState(editTarget ? 1 : 0);
|
||||||
{ label: string; description: string; needsResource: boolean; needsBaseUrl: boolean }
|
const createMutation = useCreateProvider();
|
||||||
> = {
|
const updateMutation = useUpdateProvider();
|
||||||
openai: {
|
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||||
label: "OpenAI",
|
const [error, setError] = useState<string | null>(null);
|
||||||
description: "GPT-4o, GPT-4o-mini, GPT-3.5-turbo",
|
|
||||||
needsResource: false,
|
|
||||||
needsBaseUrl: false,
|
|
||||||
},
|
|
||||||
anthropic: {
|
|
||||||
label: "Anthropic",
|
|
||||||
description: "Claude 3.5 Sonnet, Claude 3 Opus, Claude 3 Haiku",
|
|
||||||
needsResource: false,
|
|
||||||
needsBaseUrl: false,
|
|
||||||
},
|
|
||||||
azure: {
|
|
||||||
label: "Azure OpenAI",
|
|
||||||
description: "Modèles OpenAI hébergés sur Azure",
|
|
||||||
needsResource: true,
|
|
||||||
needsBaseUrl: false,
|
|
||||||
},
|
|
||||||
mistral: {
|
|
||||||
label: "Mistral AI",
|
|
||||||
description: "Mistral Small, Mistral Large",
|
|
||||||
needsResource: false,
|
|
||||||
needsBaseUrl: false,
|
|
||||||
},
|
|
||||||
ollama: {
|
|
||||||
label: "Ollama (local)",
|
|
||||||
description: "Modèles locaux auto-hébergés",
|
|
||||||
needsResource: false,
|
|
||||||
needsBaseUrl: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ProvidersPage() {
|
const form = useForm<ProviderFormValues>({
|
||||||
const [step, setStep] = useState(0);
|
resolver: zodResolver(providerSchema),
|
||||||
const [step1Data, setStep1Data] = useState<Step1 | null>(null);
|
defaultValues: {
|
||||||
const [step2Data, setStep2Data] = useState<Step2 | null>(null);
|
provider: editTarget?.provider ?? "openai",
|
||||||
const [completed, setCompleted] = useState(false);
|
display_name: editTarget?.display_name ?? "",
|
||||||
|
api_key: "", // never pre-fill API key
|
||||||
const form1 = useForm<Step1>({
|
base_url: editTarget?.base_url ?? "",
|
||||||
resolver: zodResolver(step1Schema),
|
resource_name: editTarget?.resource_name ?? "",
|
||||||
defaultValues: { provider: "openai" },
|
deployment_id: editTarget?.deployment_id ?? "",
|
||||||
|
api_version: editTarget?.api_version ?? "",
|
||||||
|
model_patterns: editTarget?.model_patterns?.join(", ") ?? "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const form2 = useForm<Step2>({
|
const selectedProvider = form.watch("provider");
|
||||||
resolver: zodResolver(step2Schema),
|
|
||||||
defaultValues: { api_key: "" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedProvider = form1.watch("provider");
|
|
||||||
const info = PROVIDER_INFO[selectedProvider];
|
const info = PROVIDER_INFO[selectedProvider];
|
||||||
|
const formValues = form.watch();
|
||||||
|
|
||||||
const handleStep1 = form1.handleSubmit((data) => {
|
const handleSubmit = form.handleSubmit(async (data) => {
|
||||||
setStep1Data(data);
|
setError(null);
|
||||||
setStep(1);
|
const patterns = data.model_patterns
|
||||||
});
|
? data.model_patterns.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
const handleStep2 = form2.handleSubmit((data) => {
|
const payload: CreateProviderRequest = {
|
||||||
setStep2Data(data);
|
provider: data.provider,
|
||||||
setStep(2);
|
display_name: data.display_name ?? "",
|
||||||
});
|
api_key: data.api_key ?? "",
|
||||||
|
base_url: data.base_url ?? "",
|
||||||
const handleConfirm = () => {
|
resource_name: data.resource_name ?? "",
|
||||||
// Backend provider config API not yet implemented
|
deployment_id: data.deployment_id ?? "",
|
||||||
setCompleted(true);
|
api_version: data.api_version ?? "",
|
||||||
|
model_patterns: patterns,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (completed) {
|
try {
|
||||||
return (
|
if (editTarget) {
|
||||||
<div className="flex flex-col items-center justify-center h-64 text-center space-y-4">
|
await updateMutation.mutateAsync({ id: editTarget.id, data: payload });
|
||||||
<div className="h-12 w-12 rounded-full bg-green-100 flex items-center justify-center">
|
} else {
|
||||||
<Check className="h-6 w-6 text-green-600" />
|
await createMutation.mutateAsync(payload);
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-lg">Configuration sauvegardée</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Le fournisseur sera actif au prochain redémarrage du proxy.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => { setStep(0); setCompleted(false); form1.reset(); form2.reset(); }}>
|
|
||||||
Configurer un autre fournisseur
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Erreur lors de la sauvegarde");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6 max-w-2xl">
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold">Fournisseurs IA</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Configurez vos connexions aux fournisseurs LLM
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Alert>
|
|
||||||
<Plug className="h-4 w-4" />
|
|
||||||
<AlertTitle>Configuration via variables d'environnement</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Les clés API sont actuellement gérées via des variables d'environnement dans
|
|
||||||
docker-compose.yml. L'interface de gestion dynamique sera disponible dans un prochain sprint.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{/* Step indicator */}
|
{/* Step indicator */}
|
||||||
|
{!editTarget && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{STEPS.map((label, idx) => (
|
{STEPS.map((label, idx) => (
|
||||||
<div key={label} className="flex items-center gap-2">
|
<div key={label} className="flex items-center gap-2">
|
||||||
<div
|
<div className={cn(
|
||||||
className={cn(
|
|
||||||
"h-7 w-7 rounded-full flex items-center justify-center text-xs font-semibold",
|
"h-7 w-7 rounded-full flex items-center justify-center text-xs font-semibold",
|
||||||
idx < step
|
idx < step ? "bg-green-100 text-green-700"
|
||||||
? "bg-green-100 text-green-700"
|
: idx === step ? "bg-primary text-primary-foreground"
|
||||||
: idx === step
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted text-muted-foreground"
|
: "bg-muted text-muted-foreground"
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
{idx < step ? <Check className="h-3.5 w-3.5" /> : idx + 1}
|
{idx < step ? <Check className="h-3.5 w-3.5" /> : idx + 1}
|
||||||
</div>
|
</div>
|
||||||
<span className={cn("text-sm", idx === step ? "font-medium" : "text-muted-foreground")}>
|
<span className={cn("text-sm", idx === step ? "font-medium" : "text-muted-foreground")}>
|
||||||
@ -162,151 +137,348 @@ export function ProvidersPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Step 1: Choose provider */}
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* Step 0: Choose provider */}
|
||||||
{step === 0 && (
|
{step === 0 && (
|
||||||
<form onSubmit={handleStep1} className="space-y-4 rounded-lg border p-6">
|
<div className="space-y-4 rounded-lg border p-6">
|
||||||
<h3 className="font-semibold">Choisir un fournisseur</h3>
|
<h3 className="font-semibold">Choisir un fournisseur</h3>
|
||||||
<div className="grid grid-cols-1 gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
{Object.entries(PROVIDER_INFO).map(([key, pinfo]) => (
|
{Object.entries(PROVIDER_INFO).map(([key, pinfo]) => (
|
||||||
<label
|
<label key={key} className={cn(
|
||||||
key={key}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-4 p-4 rounded-lg border cursor-pointer transition-colors",
|
"flex items-center gap-4 p-4 rounded-lg border cursor-pointer transition-colors",
|
||||||
selectedProvider === key
|
selectedProvider === key ? "border-primary bg-primary/5" : "hover:border-muted-foreground/40"
|
||||||
? "border-primary bg-primary/5"
|
)}>
|
||||||
: "hover:border-muted-foreground/40"
|
<input type="radio" value={key} {...form.register("provider")} className="accent-primary" />
|
||||||
)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
value={key}
|
|
||||||
{...form1.register("provider")}
|
|
||||||
className="accent-primary"
|
|
||||||
/>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">{pinfo.label}</p>
|
<p className="font-medium text-sm">{pinfo.label}</p>
|
||||||
<p className="text-xs text-muted-foreground">{pinfo.description}</p>
|
<p className="text-xs text-muted-foreground">{pinfo.description}</p>
|
||||||
</div>
|
</div>
|
||||||
{selectedProvider === key && (
|
{selectedProvider === key && <Badge variant="outline" className="ml-auto">Sélectionné</Badge>}
|
||||||
<Badge variant="outline" className="ml-auto">Sélectionné</Badge>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full">
|
<Button type="button" className="w-full" onClick={() => setStep(1)}>
|
||||||
Suivant <ChevronRight className="h-4 w-4 ml-1" />
|
Suivant <ChevronRight className="h-4 w-4 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Connection details */}
|
{/* Step 1: Connection details */}
|
||||||
{step === 1 && step1Data && (
|
{step === 1 && (
|
||||||
<form onSubmit={handleStep2} className="space-y-4 rounded-lg border p-6">
|
<div className="space-y-4 rounded-lg border p-6">
|
||||||
<h3 className="font-semibold">Paramètres de connexion — {info.label}</h3>
|
<h3 className="font-semibold">
|
||||||
|
Paramètres — {PROVIDER_INFO[selectedProvider]?.label}
|
||||||
|
</h3>
|
||||||
|
|
||||||
{info.needsBaseUrl ? (
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="display_name">Nom d'affichage (optionnel)</Label>
|
||||||
|
<Input id="display_name" {...form.register("display_name")} placeholder="Mon OpenAI" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{info?.needsApiKey && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="api_key">
|
||||||
|
Clé API {editTarget && <span className="text-muted-foreground">(laisser vide pour conserver)</span>}
|
||||||
|
</Label>
|
||||||
|
<Input id="api_key" type="password" {...form.register("api_key")} placeholder="sk-..." />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{info?.needsBaseUrl && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="base_url">URL de base</Label>
|
<Label htmlFor="base_url">URL de base</Label>
|
||||||
<Input
|
<Input id="base_url" {...form.register("base_url")} placeholder="http://localhost:11434/v1" />
|
||||||
id="base_url"
|
|
||||||
{...form2.register("base_url")}
|
|
||||||
placeholder="http://localhost:11434/v1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="api_key">Clé API *</Label>
|
|
||||||
<Input
|
|
||||||
id="api_key"
|
|
||||||
type="password"
|
|
||||||
{...form2.register("api_key")}
|
|
||||||
placeholder="sk-..."
|
|
||||||
/>
|
|
||||||
{form2.formState.errors.api_key && (
|
|
||||||
<p className="text-xs text-destructive">
|
|
||||||
{form2.formState.errors.api_key.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{info.needsResource && (
|
{info?.needsResource && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="resource_name">Resource Name (Azure)</Label>
|
<Label htmlFor="resource_name">Resource Name (Azure)</Label>
|
||||||
<Input
|
<Input id="resource_name" {...form.register("resource_name")} placeholder="my-azure-resource" />
|
||||||
id="resource_name"
|
|
||||||
{...form2.register("resource_name")}
|
|
||||||
placeholder="my-azure-resource"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="deployment_id">Deployment ID</Label>
|
<Label htmlFor="deployment_id">Deployment ID</Label>
|
||||||
<Input
|
<Input id="deployment_id" {...form.register("deployment_id")} placeholder="gpt-4o" />
|
||||||
id="deployment_id"
|
|
||||||
{...form2.register("deployment_id")}
|
|
||||||
placeholder="gpt-4o"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="model_patterns">Patterns de modèles (optionnel, séparés par virgule)</Label>
|
||||||
|
<Input id="model_patterns" {...form.register("model_patterns")}
|
||||||
|
placeholder="tinyllama, deepseek-local, mon-modele" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Noms de modèles supplémentaires à router vers ce fournisseur
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => setStep(0)}>
|
{!editTarget && (
|
||||||
Retour
|
<Button type="button" variant="outline" onClick={() => setStep(0)}>Retour</Button>
|
||||||
</Button>
|
)}
|
||||||
<Button type="submit" className="flex-1">
|
{editTarget && (
|
||||||
Suivant <ChevronRight className="h-4 w-4 ml-1" />
|
<Button type="button" variant="outline" onClick={onClose}>Annuler</Button>
|
||||||
|
)}
|
||||||
|
<Button type="button" className="flex-1" onClick={() => {
|
||||||
|
if (editTarget) {
|
||||||
|
void handleSubmit();
|
||||||
|
} else {
|
||||||
|
setStep(2);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{editTarget ? "Sauvegarder" : "Suivant"}
|
||||||
|
{!editTarget && <ChevronRight className="h-4 w-4 ml-1" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 3: Confirmation */}
|
{/* Step 2: Confirmation */}
|
||||||
{step === 2 && step1Data && step2Data && (
|
{step === 2 && !editTarget && (
|
||||||
<div className="space-y-4 rounded-lg border p-6">
|
<div className="space-y-4 rounded-lg border p-6">
|
||||||
<h3 className="font-semibold">Confirmation</h3>
|
<h3 className="font-semibold">Confirmation</h3>
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between py-2 border-b">
|
<Row label="Fournisseur" value={PROVIDER_INFO[formValues.provider]?.label} />
|
||||||
<span className="text-muted-foreground">Fournisseur</span>
|
{formValues.display_name && <Row label="Nom" value={formValues.display_name} />}
|
||||||
<span className="font-medium">{PROVIDER_INFO[step1Data.provider].label}</span>
|
{formValues.api_key && <Row label="Clé API" value={formValues.api_key.slice(0, 8) + "••••••••"} mono />}
|
||||||
|
{formValues.base_url && <Row label="URL" value={formValues.base_url} mono />}
|
||||||
|
{formValues.resource_name && <Row label="Resource" value={formValues.resource_name} mono />}
|
||||||
|
{formValues.deployment_id && <Row label="Deployment" value={formValues.deployment_id} mono />}
|
||||||
|
{formValues.model_patterns && <Row label="Patterns" value={formValues.model_patterns} mono />}
|
||||||
</div>
|
</div>
|
||||||
{step2Data.api_key && (
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
<div className="flex justify-between py-2 border-b">
|
|
||||||
<span className="text-muted-foreground">Clé API</span>
|
|
||||||
<span className="font-mono">{step2Data.api_key.slice(0, 8)}••••••••</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{step2Data.base_url && (
|
|
||||||
<div className="flex justify-between py-2 border-b">
|
|
||||||
<span className="text-muted-foreground">URL</span>
|
|
||||||
<span className="font-mono">{step2Data.base_url}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{step2Data.resource_name && (
|
|
||||||
<div className="flex justify-between py-2 border-b">
|
|
||||||
<span className="text-muted-foreground">Resource</span>
|
|
||||||
<span className="font-mono">{step2Data.resource_name}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertDescription>
|
|
||||||
La configuration sera appliquée via une variable d'environnement. Vous devrez
|
|
||||||
redémarrer le service proxy pour l'activer.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="outline" onClick={() => setStep(1)}>
|
<Button type="button" variant="outline" onClick={() => setStep(1)}>Retour</Button>
|
||||||
Retour
|
<Button type="submit" className="flex-1" disabled={isLoading}>
|
||||||
</Button>
|
{isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Check className="h-4 w-4 mr-2" />}
|
||||||
<Button onClick={handleConfirm} className="flex-1">
|
Confirmer
|
||||||
<Check className="h-4 w-4 mr-2" /> Confirmer
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value, mono }: { label: string; value?: string; mono?: boolean }) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between py-2 border-b">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className={mono ? "font-mono" : "font-medium"}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provider card ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ProviderCardProps {
|
||||||
|
config: ProviderConfig;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderCard({ config, onEdit, onDelete }: ProviderCardProps) {
|
||||||
|
const testMutation = useTestProvider();
|
||||||
|
const [testResult, setTestResult] = useState<{ healthy: boolean; error?: string } | null>(null);
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const result = await testMutation.mutateAsync(config.id);
|
||||||
|
setTestResult(result);
|
||||||
|
} catch {
|
||||||
|
setTestResult({ healthy: false, error: "Connexion échouée" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateColor: Record<string, string> = {
|
||||||
|
closed: "bg-green-100 text-green-700",
|
||||||
|
open: "bg-red-100 text-red-700",
|
||||||
|
half_open: "bg-yellow-100 text-yellow-700",
|
||||||
|
};
|
||||||
|
const stateLabel: Record<string, string> = {
|
||||||
|
closed: "Opérationnel",
|
||||||
|
open: "En panne",
|
||||||
|
half_open: "Récupération",
|
||||||
|
};
|
||||||
|
|
||||||
|
const info = PROVIDER_INFO[config.provider];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-card p-4 flex items-start gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold text-sm">
|
||||||
|
{config.display_name || info?.label || config.provider}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-xs capitalize">{config.provider}</Badge>
|
||||||
|
{config.state && (
|
||||||
|
<span className={cn("text-xs px-2 py-0.5 rounded-full font-medium", stateColor[config.state])}>
|
||||||
|
{stateLabel[config.state] ?? config.state}
|
||||||
|
{config.failures ? ` (${config.failures} échecs)` : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!config.is_active && (
|
||||||
|
<Badge variant="secondary" className="text-xs">Inactif</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{config.base_url && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 font-mono truncate">{config.base_url}</p>
|
||||||
|
)}
|
||||||
|
{config.api_key && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 font-mono">{config.api_key}</p>
|
||||||
|
)}
|
||||||
|
{config.model_patterns.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Patterns : {config.model_patterns.join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{testResult && (
|
||||||
|
<p className={cn("text-xs mt-1 font-medium", testResult.healthy ? "text-green-600" : "text-destructive")}>
|
||||||
|
{testResult.healthy ? "✓ Connexion réussie" : `✗ ${testResult.error ?? "Échec"}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost" size="sm"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testMutation.isPending}
|
||||||
|
title="Tester la connexion"
|
||||||
|
>
|
||||||
|
{testMutation.isPending
|
||||||
|
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
: testResult?.healthy
|
||||||
|
? <Zap className="h-4 w-4 text-green-600" />
|
||||||
|
: testResult
|
||||||
|
? <ZapOff className="h-4 w-4 text-destructive" />
|
||||||
|
: <RefreshCw className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onEdit} title="Modifier">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onDelete} className="text-destructive hover:text-destructive" title="Supprimer">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function ProvidersPage() {
|
||||||
|
const { data: configs = [], isLoading } = useProviderConfigs();
|
||||||
|
const deleteMutation = useDeleteProvider();
|
||||||
|
|
||||||
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
|
const [editTarget, setEditTarget] = useState<ProviderConfig | undefined>(undefined);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ProviderConfig | null>(null);
|
||||||
|
|
||||||
|
const handleEdit = (cfg: ProviderConfig) => {
|
||||||
|
setEditTarget(cfg);
|
||||||
|
setShowWizard(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseWizard = () => {
|
||||||
|
setShowWizard(false);
|
||||||
|
setEditTarget(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync(deleteTarget.id);
|
||||||
|
} finally {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showWizard) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold">
|
||||||
|
{editTarget ? `Modifier — ${PROVIDER_INFO[editTarget.provider]?.label}` : "Ajouter un fournisseur IA"}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{editTarget ? "Mettez à jour la configuration du fournisseur" : "Configurez une connexion LLM"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ProviderWizard editTarget={editTarget} onClose={handleCloseWizard} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold">Fournisseurs IA</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{configs.length} fournisseur{configs.length !== 1 ? "s" : ""} configuré{configs.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => { setEditTarget(undefined); setShowWizard(true); }}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" /> Ajouter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Chargement…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && configs.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||||
|
<p className="font-medium">Aucun fournisseur configuré</p>
|
||||||
|
<p className="text-sm mt-1">Cliquez sur « Ajouter » pour configurer votre premier fournisseur LLM.</p>
|
||||||
|
<Button className="mt-4" onClick={() => setShowWizard(true)}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" /> Ajouter un fournisseur
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{configs.map((cfg) => (
|
||||||
|
<ProviderCard
|
||||||
|
key={cfg.id}
|
||||||
|
config={cfg}
|
||||||
|
onEdit={() => handleEdit(cfg)}
|
||||||
|
onDelete={() => setDeleteTarget(cfg)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog */}
|
||||||
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Supprimer le fournisseur ?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
La configuration de{" "}
|
||||||
|
<strong>{deleteTarget ? PROVIDER_INFO[deleteTarget.provider]?.label : ""}</strong> sera supprimée.
|
||||||
|
Le proxy reviendra aux paramètres par défaut (variables d'environnement).
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Annuler</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleConfirmDelete}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { Users, UserCheck, UserX } from "lucide-react";
|
import { useRef, useState } from "react";
|
||||||
|
import { Plus, Upload, Pencil, Trash2, UserCheck, UserX, Users, AlertCircle } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@ -9,7 +12,17 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { useListUsers } from "@/api/users";
|
import {
|
||||||
|
useListUsers,
|
||||||
|
useCreateUser,
|
||||||
|
useUpdateUser,
|
||||||
|
useDeleteUser,
|
||||||
|
useImportUsers,
|
||||||
|
type UserPayload,
|
||||||
|
type ImportResult,
|
||||||
|
} from "@/api/users";
|
||||||
|
import { UserForm } from "@/components/UserForm";
|
||||||
|
import type { User } from "@/types/api";
|
||||||
|
|
||||||
const roleVariant: Record<string, "default" | "secondary" | "outline" | "warning"> = {
|
const roleVariant: Record<string, "default" | "secondary" | "outline" | "warning"> = {
|
||||||
admin: "default",
|
admin: "default",
|
||||||
@ -18,30 +31,176 @@ const roleVariant: Record<string, "default" | "secondary" | "outline" | "warning
|
|||||||
auditor: "warning",
|
auditor: "warning",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── CSV parsing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseCSV(text: string): UserPayload[] {
|
||||||
|
const lines = text.trim().split(/\r?\n/);
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
|
||||||
|
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||||
|
const hasFirstLast = headers.includes("first_name") && headers.includes("last_name");
|
||||||
|
|
||||||
|
return lines.slice(1).flatMap((line) => {
|
||||||
|
const cols = line.split(",").map((c) => c.trim().replace(/^"|"$/g, ""));
|
||||||
|
const get = (key: string) => cols[headers.indexOf(key)] ?? "";
|
||||||
|
|
||||||
|
const email = get("email");
|
||||||
|
if (!email) return [];
|
||||||
|
|
||||||
|
const name = hasFirstLast
|
||||||
|
? `${get("first_name")} ${get("last_name")}`.trim()
|
||||||
|
: get("name");
|
||||||
|
|
||||||
|
return [{
|
||||||
|
email,
|
||||||
|
name: name || email,
|
||||||
|
department: get("department"),
|
||||||
|
role: get("role") || "user",
|
||||||
|
is_active: true,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function UsersPage() {
|
export function UsersPage() {
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<User | undefined>();
|
||||||
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const csvInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const { data: users = [], isLoading } = useListUsers();
|
const { data: users = [], isLoading } = useListUsers();
|
||||||
|
const createMutation = useCreateUser();
|
||||||
|
const updateMutation = useUpdateUser();
|
||||||
|
const deleteMutation = useDeleteUser();
|
||||||
|
const importMutation = useImportUsers();
|
||||||
|
|
||||||
const activeCount = users.filter((u) => u.is_active).length;
|
const activeCount = users.filter((u) => u.is_active).length;
|
||||||
const inactiveCount = users.length - activeCount;
|
const inactiveCount = users.length - activeCount;
|
||||||
|
|
||||||
|
const handleSubmit = async (payload: UserPayload) => {
|
||||||
|
try {
|
||||||
|
if (editingUser) {
|
||||||
|
await updateMutation.mutateAsync({ id: editingUser.id, ...payload });
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(payload);
|
||||||
|
}
|
||||||
|
setFormOpen(false);
|
||||||
|
setEditingUser(undefined);
|
||||||
|
setErrorMsg(null);
|
||||||
|
} catch (e) {
|
||||||
|
setErrorMsg(e instanceof Error ? e.message : "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (user: User) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenCreate = () => {
|
||||||
|
setEditingUser(undefined);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleActive = (user: User) => {
|
||||||
|
setErrorMsg(null);
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ id: user.id, email: user.email, name: user.name, department: user.department, role: user.role, is_active: !user.is_active },
|
||||||
|
{ onError: (e) => setErrorMsg(e instanceof Error ? e.message : "Erreur lors de la mise à jour") }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (user: User) => {
|
||||||
|
if (confirm(`Supprimer définitivement "${user.name}" (${user.email}) ?`)) {
|
||||||
|
setErrorMsg(null);
|
||||||
|
deleteMutation.mutate(user.id, {
|
||||||
|
onError: (e) => setErrorMsg(e instanceof Error ? e.message : "Erreur lors de la suppression"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCSVFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
e.target.value = "";
|
||||||
|
setErrorMsg(null);
|
||||||
|
setImportResult(null);
|
||||||
|
|
||||||
|
const text = await file.text();
|
||||||
|
const rows = parseCSV(text);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
setErrorMsg("Aucun utilisateur trouvé dans le fichier. Format attendu : email,name,department,role ou email,first_name,last_name,department,role");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await importMutation.mutateAsync(rows);
|
||||||
|
setImportResult(result);
|
||||||
|
} catch (e) {
|
||||||
|
setErrorMsg(e instanceof Error ? e.message : "Erreur lors de l'import");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold">Utilisateurs</h2>
|
<h2 className="text-xl font-bold">Utilisateurs</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">Gérez les accès et les rôles des utilisateurs</p>
|
||||||
Gérez les accès et les rôles des utilisateurs
|
</div>
|
||||||
</p>
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => csvInputRef.current?.click()} disabled={importMutation.isPending}>
|
||||||
|
<Upload className="h-4 w-4 mr-2" />
|
||||||
|
{importMutation.isPending ? "Import en cours..." : "Importer CSV"}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={csvInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleCSVFile}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleOpenCreate}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Nouvel utilisateur
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{errorMsg && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription className="flex items-center justify-between">
|
||||||
|
<span>{errorMsg}</span>
|
||||||
|
<button className="ml-4 opacity-60 hover:opacity-100" onClick={() => setErrorMsg(null)}>✕</button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Import result banner */}
|
||||||
|
{importResult && (
|
||||||
|
<div className={`rounded-lg border p-3 text-sm flex items-center justify-between ${importResult.failed === 0 ? "bg-green-50 border-green-200 text-green-800" : "bg-yellow-50 border-yellow-200 text-yellow-800"}`}>
|
||||||
|
<span>
|
||||||
|
Import terminé — <strong>{importResult.success}</strong> créé(s)
|
||||||
|
{importResult.failed > 0 && <>, <strong>{importResult.failed}</strong> échoué(s) : {importResult.errors.join(", ")}</>}
|
||||||
|
</span>
|
||||||
|
<button className="ml-4 opacity-60 hover:opacity-100" onClick={() => setImportResult(null)}>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="rounded-lg border bg-card p-4 flex items-center gap-3">
|
<div className="rounded-lg border bg-card p-4 flex items-center gap-3">
|
||||||
<Users className="h-8 w-8 text-muted-foreground" />
|
<Users className="h-8 w-8 text-muted-foreground" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold">{isLoading ? "—" : users.length}</p>
|
<p className="text-2xl font-bold">{isLoading ? "—" : users.length}</p>
|
||||||
<p className="text-xs text-muted-foreground">Utilisateurs</p>
|
<p className="text-xs text-muted-foreground">Total</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-card p-4 flex items-center gap-3">
|
<div className="rounded-lg border bg-card p-4 flex items-center gap-3">
|
||||||
@ -60,6 +219,7 @@ export function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
@ -76,18 +236,19 @@ export function UsersPage() {
|
|||||||
<TableHead>Rôle</TableHead>
|
<TableHead>Rôle</TableHead>
|
||||||
<TableHead>Statut</TableHead>
|
<TableHead>Statut</TableHead>
|
||||||
<TableHead>Créé le</TableHead>
|
<TableHead>Créé le</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-10">
|
<TableCell colSpan={6} className="text-center text-muted-foreground py-10">
|
||||||
Aucun utilisateur trouvé
|
Aucun utilisateur — créez-en un ou importez un CSV.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
users.map((user) => (
|
users.map((user) => (
|
||||||
<TableRow key={user.id}>
|
<TableRow key={user.id} className={!user.is_active ? "opacity-50" : undefined}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">{user.name}</p>
|
<p className="font-medium text-sm">{user.name}</p>
|
||||||
@ -101,16 +262,36 @@ export function UsersPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge
|
<button
|
||||||
variant={user.is_active ? "success" : "secondary"}
|
onClick={() => handleToggleActive(user)}
|
||||||
className="capitalize"
|
disabled={updateMutation.isPending}
|
||||||
|
title={user.is_active ? "Désactiver" : "Réactiver"}
|
||||||
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
|
<Badge variant={user.is_active ? "success" : "secondary"}>
|
||||||
{user.is_active ? "Actif" : "Inactif"}
|
{user.is_active ? "Actif" : "Inactif"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{new Date(user.created_at).toLocaleDateString("fr-FR")}
|
{new Date(user.created_at).toLocaleDateString("fr-FR")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleEdit(user)} title="Modifier">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(user)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@ -118,6 +299,17 @@ export function UsersPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<UserForm
|
||||||
|
open={formOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setFormOpen(open);
|
||||||
|
if (!open) setEditingUser(undefined);
|
||||||
|
}}
|
||||||
|
user={editingUser}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isPending={isPending}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -93,6 +93,41 @@ export interface ProviderStatus {
|
|||||||
opened_at?: string;
|
opened_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProviderConfig {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
provider: "openai" | "anthropic" | "azure" | "mistral" | "ollama";
|
||||||
|
display_name: string;
|
||||||
|
api_key?: string; // masked on read (e.g. "sk-abc123••••••••"), plaintext on write
|
||||||
|
base_url?: string;
|
||||||
|
resource_name?: string;
|
||||||
|
deployment_id?: string;
|
||||||
|
api_version?: string;
|
||||||
|
timeout_sec: number;
|
||||||
|
max_conns: number;
|
||||||
|
model_patterns: string[];
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
// enriched by list endpoint
|
||||||
|
state?: "closed" | "open" | "half_open";
|
||||||
|
failures?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProviderRequest {
|
||||||
|
provider: "openai" | "anthropic" | "azure" | "mistral" | "ollama";
|
||||||
|
display_name?: string;
|
||||||
|
api_key?: string;
|
||||||
|
base_url?: string;
|
||||||
|
resource_name?: string;
|
||||||
|
deployment_id?: string;
|
||||||
|
api_version?: string;
|
||||||
|
timeout_sec?: number;
|
||||||
|
max_conns?: number;
|
||||||
|
model_patterns?: string[];
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export default defineConfig({
|
|||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/v1": {
|
"/v1": {
|
||||||
target: "http://localhost:8090",
|
target: process.env.VITE_PROXY_TARGET ?? "http://localhost:8090",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user