diff --git a/control-plane/api/cmd/api/main.go b/control-plane/api/cmd/api/main.go index 36a5fa0..e79e897 100644 --- a/control-plane/api/cmd/api/main.go +++ b/control-plane/api/cmd/api/main.go @@ -118,12 +118,18 @@ func main() { } // Traefik partagé + WAF Coraza : installé une fois pour toutes les démos - // (idempotent). Non-fatal : si ça échoue, l'API démarre quand même, mais - // les démos provisionnées ensuite ne seront ni routées ni protégées tant - // que ce n'est pas corrigé. - if err := helmProv.EnsureSharedInfra(); err != nil { - log.Printf("ATTENTION: infra partagée (Traefik/WAF) indisponible: %v", err) - } + // (idempotent). En arrière-plan et non-fatal : "helm upgrade --install + // --wait --timeout 5m" peut bloquer plusieurs minutes si le cluster est + // lent/injoignable, ce qui retardait le démarrage du serveur HTTP au + // point de faire échouer le healthcheck Docker avant même que l'API + // n'écoute sur le port. Ici ça ne retarde plus rien : les démos + // provisionnées avant la fin de cet appel ne seront juste ni routées ni + // protégées tant qu'il n'a pas fini. + go func() { + if err := helmProv.EnsureSharedInfra(); err != nil { + log.Printf("ATTENTION: infra partagée (Traefik/WAF) indisponible: %v", err) + } + }() // Service de démos avec le provisioner Helm demoSvc := demos.NewService(demoStore, demoPool, helmProv, demos.Config{ diff --git a/control-plane/api/internal/demos/handler.go b/control-plane/api/internal/demos/handler.go index 7761f7e..5b05b31 100644 --- a/control-plane/api/internal/demos/handler.go +++ b/control-plane/api/internal/demos/handler.go @@ -24,11 +24,14 @@ type createRequest struct { } type DetailDemoUserRequest struct { - Username string `json:"username" binding:"required,min=3,max=64,alphanum"` + // Namespace identifie la démo de façon unique. Le username ne suffit + // pas : un même client peut avoir plusieurs démos (historique, relances), + // et interroger par username renvoyait toujours la même (la plus + // ancienne), jamais celle réellement sélectionnée dans l'UI. + Namespace string `json:"namespace" binding:"required,min=3,max=63"` } type DemoDetailsResponse struct { - Username string `json:"username"` Namespace string `json:"namespace"` State ResourceState `json:"state"` } @@ -42,11 +45,14 @@ func (h *Handler) Create(c *gin.Context) { } d, err := h.svc.Create(req.LeadID, req.Username) if err != nil { - if errors.Is(err, ErrCapacityReached) { + switch { + case errors.Is(err, ErrCapacityReached): c.JSON(http.StatusConflict, gin.H{"error": "capacité maximale de démos atteinte"}) - return + case errors.Is(err, ErrUserHasActiveDemo): + c.JSON(http.StatusConflict, gin.H{"error": "ce client a déjà une démo active"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) } - c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) return } c.JSON(http.StatusAccepted, d) @@ -62,6 +68,21 @@ func (h *Handler) List(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"items": items}) } +// ListMine : GET /demos/mine — démo(s) rattachée(s) au client authentifié. +func (h *Handler) ListMine(c *gin.Context) { + p := auth.PrincipalFrom(c) + if p == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) + return + } + items, err := h.svc.ListForUser(p.Username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"}) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} + func (h *Handler) ListDetails(c *gin.Context) { p := auth.PrincipalFrom(c) if p == nil { @@ -74,26 +95,19 @@ func (h *Handler) ListDetails(c *gin.Context) { } var req DetailDemoUserRequest - if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "username requis"}) + if err := c.ShouldBindJSON(&req); err != nil || req.Namespace == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "namespace requis"}) return } - demo, err := h.svc.store.GetDemoByUsername(req.Username) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"}) - return - } - - state, err := h.helm.GetResourceState(c.Request.Context(), demo.Namespace) + state, err := h.helm.GetResourceState(c.Request.Context(), req.Namespace) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "impossible de récupérer l'état des ressources"}) return } c.JSON(http.StatusOK, DemoDetailsResponse{ - Username: req.Username, - Namespace: demo.Namespace, + Namespace: req.Namespace, State: state, }) } diff --git a/control-plane/api/internal/demos/service.go b/control-plane/api/internal/demos/service.go index 2070e6e..3c8bf49 100644 --- a/control-plane/api/internal/demos/service.go +++ b/control-plane/api/internal/demos/service.go @@ -11,9 +11,10 @@ import ( ) var ( - ErrCapacityReached = errors.New("capacité maximale de démos atteinte") - ErrNotFound = errors.New("démo introuvable") - ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel") + ErrCapacityReached = errors.New("capacité maximale de démos atteinte") + ErrNotFound = errors.New("démo introuvable") + ErrNotExtendable = errors.New("démo non prolongeable dans son état actuel") + ErrUserHasActiveDemo = errors.New("ce client a déjà une démo active") ) // Config du service (injectable pour les tests). @@ -50,8 +51,22 @@ func NewService(store Store, pool Pool, prov Provisioner, cfg Config) *Service { } // Create réserve la capacité + le pool, persiste la démo et déclenche le worker. -// username (optionnel) rattache la démo à un client existant. +// username (optionnel) rattache la démo à un client existant. Un client n'a +// droit qu'à une seule démo active à la fois. func (s *Service) Create(leadID, username string) (Demo, error) { + normalizedUsername := auth.NormalizeUsername(username) + if normalizedUsername != "" { + existing, err := s.store.ListByUsername(normalizedUsername) + if err != nil { + return Demo{}, err + } + for _, d := range existing { + if d.Status.Active() { + return Demo{}, ErrUserHasActiveDemo + } + } + } + active, err := s.store.CountActive() if err != nil { return Demo{}, err @@ -63,7 +78,7 @@ func (s *Service) Create(leadID, username string) (Demo, error) { id := uuid.NewString() demo := Demo{ ID: id, - Username: auth.NormalizeUsername(username), + Username: normalizedUsername, LeadID: leadID, Status: StatusPending, Namespace: "demo-" + shortID(id), diff --git a/control-plane/api/internal/profile/handler.go b/control-plane/api/internal/profile/handler.go index 0ce60d8..b1abd87 100644 --- a/control-plane/api/internal/profile/handler.go +++ b/control-plane/api/internal/profile/handler.go @@ -35,13 +35,13 @@ func (h *Handler) UpdateUsernameById(c *gin.Context) { return } - id, ok := c.MustGet("id").(string) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"}) + p := auth.PrincipalFrom(c) + if p == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) return } - user, err := h.store.UpdateUsername(id, u.Username) + user, err := h.store.UpdateUsername(p.UserID, u.Username) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"}) return @@ -57,9 +57,9 @@ func (h *Handler) UpdatePasswordById(c *gin.Context) { return } - id, ok := c.MustGet("id").(string) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"}) + p := auth.PrincipalFrom(c) + if p == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) return } @@ -69,7 +69,7 @@ func (h *Handler) UpdatePasswordById(c *gin.Context) { return } - user, err := h.store.UpdatePassword(id, hash) + user, err := h.store.UpdatePassword(p.UserID, hash) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) return @@ -79,12 +79,12 @@ func (h *Handler) UpdatePasswordById(c *gin.Context) { } func (h *Handler) GetTelegramById(c *gin.Context) { - id, ok := c.MustGet("id").(string) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"}) + p := auth.PrincipalFrom(c) + if p == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) return } - user, err := h.store.GetTelegram(id) + user, err := h.store.GetTelegram(p.UserID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) return @@ -98,12 +98,12 @@ func (h *Handler) SetTelegramById(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"}) return } - id, ok := c.MustGet("id").(string) - if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "id invalide"}) + p := auth.PrincipalFrom(c) + if p == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"}) return } - user, err := h.store.SetTelegram(id, t.Telegram) + user, err := h.store.SetTelegram(p.UserID, t.Telegram) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"}) return diff --git a/control-plane/api/internal/router/router.go b/control-plane/api/internal/router/router.go index 711c048..abf3a7d 100644 --- a/control-plane/api/internal/router/router.go +++ b/control-plane/api/internal/router/router.go @@ -63,6 +63,9 @@ func New(d Deps) *gin.Engine { client.GET("/leads", d.LeadsH.List) client.PATCH("/leads/:id/status", d.LeadsH.SetStatus) client.POST("/subscription", d.SubH.AddCode) + if d.DemosH != nil { + client.GET("/demos/mine", d.DemosH.ListMine) + } } // Espace admin : provisioning des démos (admin uniquement). admin := authed.Group("") diff --git a/deploy/chart-gestion/traefik/values.yaml b/deploy/chart-gestion/traefik/values.yaml index 88f1cc2..781fe10 100644 --- a/deploy/chart-gestion/traefik/values.yaml +++ b/deploy/chart-gestion/traefik/values.yaml @@ -88,11 +88,17 @@ traefik: enabled: true allowCrossNamespace: true - # IP réelle via X-Forwarded-For (profondeur 1 = proxy direct) - # Le rate-limiter et les logs utilisent cette IP + # Cluster (pas Local) : sur un cluster bare-metal multi-nœuds sans + # LoadBalancer, "Local" fait DROP le trafic NodePort sur tout nœud qui ne + # fait pas tourner le pod Traefik (comportement kube-proxy documenté — + # "has no local endpoints"), donc l'URL casse selon le nœud contacté et + # où le pod est schedulé. "Cluster" relaie toujours vers le bon nœud, au + # prix de perdre l'IP réelle du client (SNAT par kube-proxy) — le + # rate-limiter/logs verront l'IP du nœud plutôt que celle du client tant + # qu'il n'y a pas de LoadBalancer (MetalLB) devant. service: spec: - externalTrafficPolicy: Local + externalTrafficPolicy: Cluster # Métriques Prometheus metrics: diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index b633208..4707fa5 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -104,7 +104,6 @@ export interface DemoState { } export interface DemoDetails { - username: string namespace: string state: DemoState } @@ -150,6 +149,7 @@ export const api = { request('PATCH', `/leads/${id}/status`, { status }), listDemos: () => request<{ items: Demo[] }>('GET', '/demos'), + listMyDemos: () => request<{ items: Demo[] }>('GET', '/demos/mine'), getDemo: (id: string) => request('GET', `/demos/${id}`), createDemo: (username: string, leadId?: string) => request('POST', '/demos', { @@ -167,8 +167,8 @@ export const api = { sendMessage: (username: string, telegram: string, sujet: string, message: string) => request<{ success: string }>('POST', '/send/message', { username, telegram, sujet, message }), getMessage: () => request<{ messages: Contact[] }>('GET', '/messages'), - getDemoDetails: (username: string) => - request('POST', '/demos/details', { username }), + getDemoDetails: (namespace: string) => + request('POST', '/demos/details', { namespace }), updateUsername: (username: string) => request('POST', '/profile/username', { username }), updatePassword: (password: string) => diff --git a/web/src/pages/backoffice/Demos.tsx b/web/src/pages/backoffice/Demos.tsx index 83d75ef..8b1ce5d 100644 --- a/web/src/pages/backoffice/Demos.tsx +++ b/web/src/pages/backoffice/Demos.tsx @@ -140,7 +140,7 @@ export function Demos() { setDetails(null) setDetailsLoading(true) try { - const res = await api.getDemoDetails(d.username) + const res = await api.getDemoDetails(d.namespace) setDetails(res) } catch (err) { const msg = err instanceof ApiError ? err.message : 'Erreur' @@ -156,10 +156,10 @@ export function Demos() { useEffect(() => { const current = demos.find((d) => d.id === expandedId) if (!current) return - const username = current.username + const namespace = current.namespace const id = setInterval(() => { api - .getDemoDetails(username) + .getDemoDetails(namespace) .then(setDetails) .catch(() => { /* échec silencieux : on garde le dernier état connu affiché */ diff --git a/web/src/pages/backoffice/Subscription.tsx b/web/src/pages/backoffice/Subscription.tsx index c082b50..67dfc0a 100644 --- a/web/src/pages/backoffice/Subscription.tsx +++ b/web/src/pages/backoffice/Subscription.tsx @@ -9,6 +9,7 @@ import { Heading, HStack, Input, + Link, Spacer, Spinner, Stack, @@ -17,7 +18,8 @@ import { useToast, } from '@chakra-ui/react' import { useNavigate } from 'react-router-dom' -import { api, ApiError } from '../../lib/api' +import { api, ApiError, type Demo } from '../../lib/api' +import { statusColor, statusLabel } from '../../lib/format' export function Subscription() { const toast = useToast() @@ -28,6 +30,8 @@ export function Subscription() { const [busy, setBusy] = useState(false) const [code, setCode] = useState('') const [showRenewForm, setShowRenewForm] = useState(false) + const [demos, setDemos] = useState([]) + const [demosLoading, setDemosLoading] = useState(true) const load = useCallback(async () => { try { @@ -42,9 +46,21 @@ export function Subscription() { } }, [navigate, toast]) + const loadDemos = useCallback(async () => { + try { + const res = await api.listMyDemos() + setDemos(res.items ?? []) + } catch { + // Silencieux : l'absence de démo n'est pas une erreur à afficher ici. + } finally { + setDemosLoading(false) + } + }, []) + useEffect(() => { void load() - }, [load]) + void loadDemos() + }, [load, loadDemos]) const submitCode = async (e: React.FormEvent) => { e.preventDefault() @@ -76,6 +92,36 @@ export function Subscription() { return ( <> + + Ma démo + + + + + {demosLoading ? ( + + ) : demos.length === 0 ? ( + Aucune démo pour le moment. + ) : ( + + {demos.map((d) => ( + + {d.status === 'ready' ? ( + + {d.url} + + ) : ( + + {d.url || '—'} + + )} + {statusLabel(d.status)} + + ))} + + )} + + Mon abonnement