chore: update
ci-api / test (push) Successful in 22m17s
ci-web / test (push) Successful in 13m44s

This commit is contained in:
Xor290
2026-08-02 18:34:59 +02:00
parent 6992e505ae
commit c5ee5636ef
34 changed files with 2660 additions and 498 deletions
+30
View File
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"github.com/omnex/control-plane/api/internal/alerts"
"github.com/omnex/control-plane/api/internal/auth" "github.com/omnex/control-plane/api/internal/auth"
"github.com/omnex/control-plane/api/internal/config" "github.com/omnex/control-plane/api/internal/config"
"github.com/omnex/control-plane/api/internal/db" "github.com/omnex/control-plane/api/internal/db"
@@ -67,6 +68,10 @@ func main() {
var demoPool demos.Pool var demoPool demos.Pool
var codeStore sub.Store var codeStore sub.Store
var profileStore profile.Store var profileStore profile.Store
// Résout les destinataires d'alerte pods depuis les réglages de chaque
// admin (table users) — nil si pas de base (mode mémoire, dev), auquel
// cas le monitoring reste désactivé (rien à interroger).
var alertRecipients func(ctx context.Context) ([]alerts.Notifier, error)
if cfg.DatabaseURL != "" { if cfg.DatabaseURL != "" {
gdb, err := db.Open(cfg.DatabaseURL) gdb, err := db.Open(cfg.DatabaseURL)
if err != nil { if err != nil {
@@ -85,6 +90,7 @@ func main() {
demoStore = demos.NewGormStore(gdb) demoStore = demos.NewGormStore(gdb)
demoPool = demos.NewGormPool(gdb) demoPool = demos.NewGormPool(gdb)
profileStore = profile.NewGormStore(gdb) profileStore = profile.NewGormStore(gdb)
alertRecipients = alerts.GormRecipients(gdb)
log.Printf("persistance: PostgreSQL (GORM)") log.Printf("persistance: PostgreSQL (GORM)")
} else { } else {
userStore = seedMemUsers() userStore = seedMemUsers()
@@ -141,6 +147,30 @@ func main() {
TTL: demos.TTL, TTL: demos.TTL,
}) })
// Alerting : surveille les pods des démos actives, notifie Discord et/ou
// Telegram de chaque admin ayant configuré son propre canal (voir profil
// admin, /profile/alerts). Désactivé en mode mémoire (pas de réglages
// persistés à interroger).
if alertRecipients != nil {
watcher := alerts.NewWatcher(k8sClient, alertRecipients, func() ([]string, error) {
all, err := demoSvc.List()
if err != nil {
return nil, err
}
ns := make([]string, 0, len(all))
for _, d := range all {
if d.Status.Active() {
ns = append(ns, d.Namespace)
}
}
return ns, nil
})
go watcher.Run(context.Background())
log.Printf("alerts: monitoring des pods actif (réglages par admin, voir /profile/alerts)")
} else {
log.Printf("alerts: monitoring désactivé (nécessite OMNEX_DATABASE_URL — réglages persistés par admin)")
}
deps := router.Deps{ deps := router.Deps{
Cfg: cfg, Cfg: cfg,
Issuer: iss, Issuer: iss,
@@ -0,0 +1,90 @@
// Package alerts : surveillance des pods des démos + notification (Discord,
// Telegram) quand une plateforme tombe en erreur ou se rétablit.
package alerts
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
)
// Notifier envoie un message d'alerte vers un canal externe.
type Notifier interface {
Notify(ctx context.Context, message string) error
}
// MultiNotifier diffuse vers plusieurs canaux ; une erreur sur l'un
// n'empêche pas la notification des autres.
type MultiNotifier []Notifier
func (m MultiNotifier) Notify(ctx context.Context, message string) error {
var errs []error
for _, n := range m {
if err := n.Notify(ctx, message); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
var httpClient = &http.Client{Timeout: 10 * time.Second}
// DiscordNotifier envoie le message sur un webhook Discord.
type DiscordNotifier struct {
WebhookURL string
}
func (d *DiscordNotifier) Notify(ctx context.Context, message string) error {
body, err := json.Marshal(map[string]string{"content": message})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.WebhookURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("discord: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("discord: statut %d", resp.StatusCode)
}
return nil
}
// TelegramNotifier envoie le message via un bot Telegram (sendMessage).
type TelegramNotifier struct {
BotToken string
ChatID string
}
func (t *TelegramNotifier) Notify(ctx context.Context, message string) error {
endpoint := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", t.BotToken)
body, err := json.Marshal(map[string]string{"chat_id": t.ChatID, "text": message})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("telegram: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("telegram: statut %d", resp.StatusCode)
}
return nil
}
@@ -0,0 +1,38 @@
package alerts
import (
"context"
"gorm.io/gorm"
"github.com/omnex/control-plane/api/internal/auth"
)
// GormRecipients construit, à partir de la table des utilisateurs, la liste
// des notifiers actifs : chaque admin ayant renseigné un webhook Discord
// et/ou un bot Telegram (dans son profil) reçoit les alertes. Interrogée à
// chaque notification nécessaire — un admin qui change ses réglages est pris
// en compte sans redémarrage de l'API.
func GormRecipients(gdb *gorm.DB) func(ctx context.Context) ([]Notifier, error) {
return func(ctx context.Context) ([]Notifier, error) {
var admins []auth.User
if err := gdb.WithContext(ctx).Where("role = ?", auth.RoleAdmin).Find(&admins).Error; err != nil {
return nil, err
}
var notifiers []Notifier
for _, a := range admins {
if a.AlertDiscordWebhookURL != nil && *a.AlertDiscordWebhookURL != "" {
notifiers = append(notifiers, &DiscordNotifier{WebhookURL: *a.AlertDiscordWebhookURL})
}
if a.AlertTelegramBotToken != nil && *a.AlertTelegramBotToken != "" &&
a.AlertTelegramChatID != nil && *a.AlertTelegramChatID != "" {
notifiers = append(notifiers, &TelegramNotifier{
BotToken: *a.AlertTelegramBotToken,
ChatID: *a.AlertTelegramChatID,
})
}
}
return notifiers, nil
}
}
@@ -0,0 +1,169 @@
package alerts
import (
"context"
"fmt"
"log"
"time"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
const pollInterval = 30 * time.Second
// Raisons de conteneur considérées comme des pannes (hors CrashLoopBackOff
// initial normal au tout premier démarrage d'un pod fraîchement créé — non
// distingué ici, le bruit au provisioning est acceptable).
var badWaitingReasons = map[string]bool{
"CrashLoopBackOff": true,
"ImagePullBackOff": true,
"ErrImagePull": true,
"CreateContainerConfigError": true,
"CreateContainerError": true,
"InvalidImageName": true,
"RunContainerError": true,
}
type podKey struct{ namespace, pod string }
// Watcher scrute périodiquement les pods des namespaces actifs et notifie
// tout changement d'état (sain -> panne, panne -> rétabli). Les destinataires
// (recipients) sont résolus à chaque tick où une notification est nécessaire
// — chaque admin configure son propre webhook Discord / bot Telegram dans
// son profil (voir internal/profile), rien n'est figé au démarrage.
type Watcher struct {
k8sClient *kubernetes.Clientset
recipients func(ctx context.Context) ([]Notifier, error)
namespaces func() ([]string, error)
state map[podKey]string // dernière raison de panne connue ("" = sain)
}
func NewWatcher(k8sClient *kubernetes.Clientset, recipients func(ctx context.Context) ([]Notifier, error), namespaces func() ([]string, error)) *Watcher {
return &Watcher{
k8sClient: k8sClient,
recipients: recipients,
namespaces: namespaces,
state: make(map[podKey]string),
}
}
// Run boucle jusqu'à annulation du contexte. À lancer dans une goroutine.
func (w *Watcher) Run(ctx context.Context) {
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
w.tick(ctx)
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (w *Watcher) tick(ctx context.Context) {
nsList, err := w.namespaces()
if err != nil {
log.Printf("alerts: liste des démos actives indisponible: %v", err)
return
}
// Les destinataires ne sont chargés (requête DB) que si une notification
// s'avère nécessaire ce tick-ci, et une seule fois par tick.
var notifier Notifier
notifierLoaded := false
loadNotifier := func() Notifier {
if notifierLoaded {
return notifier
}
notifierLoaded = true
list, err := w.recipients(ctx)
if err != nil {
log.Printf("alerts: chargement des destinataires échoué: %v", err)
return nil
}
if len(list) == 0 {
return nil
}
notifier = MultiNotifier(list)
return notifier
}
seen := make(map[podKey]bool, len(w.state))
for _, ns := range nsList {
pods, err := w.k8sClient.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{})
if err != nil {
log.Printf("alerts: liste des pods de %s indisponible: %v", ns, err)
continue
}
for _, pod := range pods.Items {
key := podKey{namespace: ns, pod: pod.Name}
seen[key] = true
reason := problemReason(pod)
prev, known := w.state[key]
if reason != prev {
if known {
w.notify(ctx, loadNotifier(), key, prev, reason)
} else if reason != "" {
// Premier passage sur ce pod : on ne notifie que s'il est
// déjà en panne (sinon rien d'anormal à signaler).
w.notify(ctx, loadNotifier(), key, "", reason)
}
w.state[key] = reason
}
}
}
// Pods disparus (démo supprimée, rollout) : on oublie leur état pour ne
// pas polluer la mémoire indéfiniment.
for key := range w.state {
if !seen[key] {
delete(w.state, key)
}
}
}
func (w *Watcher) notify(ctx context.Context, notifier Notifier, key podKey, prev, reason string) {
if notifier == nil {
return
}
var msg string
switch {
case prev == "" && reason != "":
msg = fmt.Sprintf("🔴 [%s] pod %s en erreur : %s", key.namespace, key.pod, reason)
case prev != "" && reason == "":
msg = fmt.Sprintf("🟢 [%s] pod %s rétabli", key.namespace, key.pod)
default:
msg = fmt.Sprintf("🔴 [%s] pod %s toujours en erreur : %s", key.namespace, key.pod, reason)
}
if err := notifier.Notify(ctx, msg); err != nil {
log.Printf("alerts: notification échouée: %v", err)
}
}
// problemReason retourne une raison de panne non vide si le pod est dans un
// état anormal (phase Failed, ou conteneur en CrashLoopBackOff/ImagePullBackOff/
// OOMKilled...), "" si tout va bien.
func problemReason(pod corev1.Pod) string {
if pod.DeletionTimestamp != nil {
return "" // suppression en cours, pas une panne
}
if pod.Status.Phase == corev1.PodFailed {
if pod.Status.Reason != "" {
return pod.Status.Reason
}
return "Failed"
}
for _, cs := range pod.Status.ContainerStatuses {
if waiting := cs.State.Waiting; waiting != nil && badWaitingReasons[waiting.Reason] {
return waiting.Reason
}
if term := cs.State.Terminated; term != nil && term.Reason == "OOMKilled" {
return "OOMKilled"
}
}
return ""
}
+8 -2
View File
@@ -30,10 +30,16 @@ func NewHandler(users UserStore, sessions session.Manager, iss *Issuer, secure b
type loginRequest struct { type loginRequest struct {
Username string Username string
Password string Password string
// Role : page de connexion utilisée ("admin" ou "client"). La page
// admin et la page client sont désormais séparées côté front — un
// compte client ne peut pas se connecter depuis la page admin et
// inversement, même avec des identifiants valides.
Role Role `json:"role" binding:"required,oneof=admin client"`
} }
// Login authentifie, ouvre une session Redis et pose le cookie httpOnly. // Login authentifie, ouvre une session Redis et pose le cookie httpOnly.
// Réponse volontairement uniforme (pas d'énumération d'utilisateurs). // Réponse volontairement uniforme (pas d'énumération d'utilisateurs, ni du
// rôle réel du compte en cas de connexion depuis la mauvaise page).
func (h *Handler) Login(c *gin.Context) { func (h *Handler) Login(c *gin.Context) {
var req loginRequest var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@@ -50,7 +56,7 @@ func (h *Handler) Login(c *gin.Context) {
} }
ok, err := VerifyPassword(req.Password, user.PasswordHash) ok, err := VerifyPassword(req.Password, user.PasswordHash)
if err != nil || !ok { if err != nil || !ok || user.Role != req.Role {
c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"})
return return
} }
+10 -2
View File
@@ -10,6 +10,14 @@ type User struct {
Role Role `gorm:"size:20;not null" json:"role"` Role Role `gorm:"size:20;not null" json:"role"`
TypeAbo string `gorm:"type:varchar(35);default:demo" json:"type_abonnement"` TypeAbo string `gorm:"type:varchar(35);default:demo" json:"type_abonnement"`
ExpiredAt time.Time `json:"expired_at"` ExpiredAt time.Time `json:"expired_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` // Réglages d'alerte monitoring (admin uniquement) : chaque admin
// configure son propre canal, aucune variable d'env globale — voir
// internal/alerts et internal/profile (Get/SetAlertSettings).
AlertDiscordWebhookURL *string `gorm:"size:500" json:"-"`
AlertTelegramBotToken *string `gorm:"size:200" json:"-"`
AlertTelegramChatID *string `gorm:"size:64" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} }
+18
View File
@@ -4,6 +4,7 @@ package demos
import ( import (
"errors" "errors"
"strings"
"time" "time"
) )
@@ -145,3 +146,20 @@ const (
ResourceFree ResourceStatus = "free" ResourceFree ResourceStatus = "free"
ResourceBorrowed ResourceStatus = "borrowed" ResourceBorrowed ResourceStatus = "borrowed"
) )
// premiumNamespace calcule le namespace "premium-<client>" utilisé une fois
// la démo passée en abonnement payant (voir Service.TransferToPaid et
// HelmProvisioner.MigrateToPremiumNamespace) — Kubernetes ne permet pas de
// renommer un namespace, seulement d'en créer un nouveau et d'y migrer les
// données. Contrainte K8s : nom en minuscules, 63 caractères max ; le
// username est déjà alphanumérique (voir handler.go), il suffit de le
// mettre en minuscule et de le tronquer si besoin.
func premiumNamespace(username string) string {
const prefix = "premium-"
maxLen := 63 - len(prefix)
u := strings.ToLower(username)
if len(u) > maxLen {
u = u[:maxLen]
}
return prefix + u
}
@@ -5,6 +5,7 @@ import (
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -206,6 +207,227 @@ func (h *HelmProvisioner) Teardown(d Demo) error {
return h.deleteNamespace(d.Namespace) return h.deleteNamespace(d.Namespace)
} }
// MigrateToPremiumNamespace reconstruit l'intégralité de la démo dans
// newNamespace ("premium-<client>") et y migre les données postgres —
// Kubernetes ne permet pas de renommer un namespace, il faut donc
// recréer toute la stack (postgres/redis/backend/frontend/ingressroute/
// lbtelegram) ailleurs.
//
// La configuration (bot Telegram, NowPayments, load-balancer...) n'est
// jamais persistée en base (voir ProvisionConfig) : elle est relue depuis
// les valeurs Helm de l'ancien déploiement ("helm get values"), qui
// contiennent exactement ce qui a été installé à l'origine, secrets
// inclus — jamais journalisés ni interprétés ici, seulement retransmis
// tels quels au nouveau déploiement. Seuls les champs qui référencent
// l'ancien namespace (DNS internes, URL du webhook Telegram) sont
// recalculés pour le nouveau (voir patchBackendValuesForNamespace /
// patchLBTelegramValuesForNamespace).
//
// L'ancien namespace n'est supprimé qu'une fois le nouveau confirmé
// opérationnel (rollout réussi) : en cas d'échec à n'importe quelle étape,
// la démo continue de fonctionner sous son ancienne adresse, rien n'est
// perdu. No-op si newNamespace == d.Namespace (déjà migrée, ex.
// renouvellement d'un client déjà premium).
func (h *HelmProvisioner) MigrateToPremiumNamespace(d Demo, newNamespace, newURL string) error {
oldNamespace := d.Namespace
if newNamespace == oldNamespace {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
backendValues, err := h.getReleaseValues(oldNamespace, oldNamespace+"-backend")
if err != nil {
return fmt.Errorf("lecture config backend existante: %w", err)
}
lbValues, lbEnabled := h.getReleaseValuesOptional(oldNamespace, oldNamespace+"-lbtelegram")
if err := h.createNamespace(newNamespace); err != nil {
return fmt.Errorf("création namespace %s: %w", newNamespace, err)
}
newDemo := d
newDemo.Namespace = newNamespace
newDemo.URL = newURL
if err := h.installChart(newNamespace, "postgresql", h.buildPostgresValues(newDemo)); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("déploiement postgresql: %w", err)
}
if err := h.installChart(newNamespace, "redis", h.buildRedisValues(newDemo)); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("déploiement redis: %w", err)
}
// Données postgres : migrées par dump/restore (le nouveau PVC est
// vide). Redis (cache/sessions) n'est pas migré, reconstruit
// naturellement — même convention que l'historique passage en
// stockage persistant.
if err := h.migratePostgresData(ctx, oldNamespace, newNamespace); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("migration données postgres: %w", err)
}
var backendLinkSecret string
if lbEnabled {
backendLinkSecret = asStringMap(backendValues["secrets"])["BACKEND_LINK_SECRET"]
if backendLinkSecret == "" {
backendLinkSecret = randomSecret()
}
}
backendPatched := h.patchBackendValuesForNamespace(backendValues, newDemo, lbEnabled, backendLinkSecret)
if err := h.installChart(newNamespace, "backend", backendPatched); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("déploiement backend: %w", err)
}
if err := h.installChart(newNamespace, "frontend", h.buildFrontendValues(newDemo)); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("déploiement frontend: %w", err)
}
if err := h.installChart(newNamespace, "ingressroute", h.buildIngressRouteValues(newDemo)); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("déploiement ingressroute: %w", err)
}
if lbEnabled {
lbPatched := h.patchLBTelegramValuesForNamespace(lbValues, newDemo, backendLinkSecret)
if err := h.installChart(newNamespace, "lbtelegram", lbPatched); err != nil {
h.deleteNamespace(newNamespace)
return fmt.Errorf("déploiement lbtelegram: %w", err)
}
}
if err := h.waitForRollout(newNamespace); err != nil {
// Le nouveau namespace n'est PAS supprimé : les données y sont déjà
// migrées, un rollout en échec est réparable manuellement sans tout
// reperdre. L'ancien namespace reste lui aussi en place tant que le
// nouveau n'est pas confirmé opérationnel.
return fmt.Errorf("rollout du nouveau namespace %s: %w", newNamespace, err)
}
if err := h.deleteNamespace(oldNamespace); err != nil {
log.Printf("Warning: suppression ancien namespace %s échouée (nouveau namespace %s opérationnel): %v", oldNamespace, newNamespace, err)
}
log.Printf("Démo %s migrée vers le namespace premium %s", oldNamespace, newNamespace)
return nil
}
// migratePostgresData copie les données postgres d'un namespace à l'autre
// (pg_dump / psql restore), utilisé par MigrateToPremiumNamespace.
func (h *HelmProvisioner) migratePostgresData(ctx context.Context, oldNamespace, newNamespace string) error {
oldPod, err := h.findPod(ctx, oldNamespace, "postgresql")
if err != nil {
return fmt.Errorf("pod postgresql source introuvable: %w", err)
}
dumpCmd := fmt.Sprintf("PGPASSWORD=%s pg_dump -h localhost -U %s %s", demoDBPass, demoDBUser, demoDBName)
dump, stderr, err := h.execInPod(ctx, oldNamespace, oldPod, "postgresql", []string{"sh", "-c", dumpCmd}, nil)
if err != nil {
return fmt.Errorf("pg_dump: %w (%s)", err, stderr)
}
if strings.TrimSpace(dump) == "" {
return nil
}
newPod, err := h.findPod(ctx, newNamespace, "postgresql")
if err != nil {
return fmt.Errorf("pod postgresql cible introuvable: %w", err)
}
restoreCmd := fmt.Sprintf("PGPASSWORD=%s psql -h localhost -U %s %s", demoDBPass, demoDBUser, demoDBName)
if _, stderr, err := h.execInPod(ctx, newNamespace, newPod, "postgresql", []string{"sh", "-c", restoreCmd}, strings.NewReader(dump)); err != nil {
return fmt.Errorf("restore pg_dump: %w (%s)", err, stderr)
}
return nil
}
// patchBackendValuesForNamespace réutilise les valeurs Helm existantes du
// backend (image, secrets, réglages métier...) en ne recalculant que ce qui
// référence le nom du namespace : DNS internes postgres/redis/lbtelegram et
// URL du webhook Telegram (dépend de l'URL publique de la démo).
func (h *HelmProvisioner) patchBackendValuesForNamespace(values map[string]interface{}, newDemo Demo, lbEnabled bool, backendLinkSecret string) map[string]interface{} {
env := asStringMap(values["env"])
secrets := asStringMap(values["secrets"])
env["DB_HOST"] = fmt.Sprintf("%s-postgresql-postgresql", newDemo.Namespace)
env["REDIS_HOST"] = fmt.Sprintf("%s-redis-redis", newDemo.Namespace)
if lbEnabled {
env["LBTELEGRAM_URL"] = fmt.Sprintf("http://%s-lbtelegram-lbtelegram.%s.svc.cluster.local:8081", newDemo.Namespace, newDemo.Namespace)
secrets["BACKEND_LINK_SECRET"] = backendLinkSecret
}
if secrets["TELEGRAM_BOT_TOKEN"] != "" {
secrets["TELEGRAM_WEBHOOK_URL"] = newDemo.URL + "/webhook/telegram"
}
values["env"] = env
values["secrets"] = secrets
return values
}
// patchLBTelegramValuesForNamespace réutilise les valeurs Helm existantes
// du chart lbtelegram (bots, stratégie, tokens...) en ne recalculant que ce
// qui référence le nom du namespace : host public, GATEWAY_URL, DSN
// postgres/redis et URL interne du backend.
func (h *HelmProvisioner) patchLBTelegramValuesForNamespace(values map[string]interface{}, newDemo Demo, backendLinkSecret string) map[string]interface{} {
env := asStringMap(values["env"])
secrets := asStringMap(values["secrets"])
host := fmt.Sprintf("%s.%s", newDemo.Namespace, h.baseDomain)
env["GATEWAY_URL"] = "https://" + host
env["BACKEND_LINK_URL"] = fmt.Sprintf("http://%s-backend-gestion-backend.%s.svc.cluster.local:8080", newDemo.Namespace, newDemo.Namespace)
secrets["BACKEND_LINK_SECRET"] = backendLinkSecret
secrets["DATABASE_URL"] = fmt.Sprintf("postgres://postgres:demo-postgres-pass@%s-postgresql-postgresql:5432/demo_db?sslmode=disable", newDemo.Namespace)
secrets["REDIS_URL"] = fmt.Sprintf("redis://:demo-redis-pass@%s-redis-redis:6379/0", newDemo.Namespace)
values["host"] = host
values["env"] = env
values["secrets"] = secrets
return values
}
// getReleaseValues récupère les valeurs Helm exactes utilisées par une
// release déjà installée ("helm get values -o json").
func (h *HelmProvisioner) getReleaseValues(namespace, release string) (map[string]interface{}, error) {
out, err := h.runHelmOutput("get", "values", release, "--namespace", namespace, "-o", "json")
if err != nil {
return nil, err
}
var values map[string]interface{}
if err := json.Unmarshal(out, &values); err != nil {
return nil, fmt.Errorf("parse valeurs helm de %s: %w", release, err)
}
return values, nil
}
// getReleaseValuesOptional : comme getReleaseValues, mais renvoie
// simplement (nil, false) si la release n'existe pas (ex. lbtelegram non
// activé pour cette démo) plutôt qu'une erreur.
func (h *HelmProvisioner) getReleaseValuesOptional(namespace, release string) (map[string]interface{}, bool) {
values, err := h.getReleaseValues(namespace, release)
if err != nil {
return nil, false
}
return values, true
}
// asStringMap convertit une valeur JSON décodée (map[string]interface{})
// en map[string]string, en ignorant silencieusement les clés dont la
// valeur n'est pas une chaîne.
func asStringMap(v interface{}) map[string]string {
out := map[string]string{}
m, ok := v.(map[string]interface{})
if !ok {
return out
}
for k, val := range m {
if s, ok := val.(string); ok {
out[k] = s
}
}
return out
}
// createGestionAdmin crée le compte admin de l'application "gestion" // createGestionAdmin crée le compte admin de l'application "gestion"
// déployée dans cette démo, en insérant directement dans la table "users" // déployée dans cette démo, en insérant directement dans la table "users"
// (aucune route API ne le permet : CreateUser refuse explicitement de créer // (aucune route API ne le permet : CreateUser refuse explicitement de créer
@@ -405,15 +627,22 @@ func (h *HelmProvisioner) writeValuesFile(namespace, chartName string, values ma
// runHelm exécute une commande helm et remonte stdout/stderr en cas d'erreur. // runHelm exécute une commande helm et remonte stdout/stderr en cas d'erreur.
func (h *HelmProvisioner) runHelm(args ...string) error { func (h *HelmProvisioner) runHelm(args ...string) error {
_, err := h.runHelmOutput(args...)
return err
}
// runHelmOutput exécute une commande helm et retourne son stdout (ex. "helm
// get values -o json").
func (h *HelmProvisioner) runHelmOutput(args ...string) ([]byte, error) {
cmd := exec.Command(h.helmPath, args...) cmd := exec.Command(h.helmPath, args...)
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return fmt.Errorf("%v (stdout: %s, stderr: %s)", err, stdout.String(), stderr.String()) return nil, fmt.Errorf("%v (stdout: %s, stderr: %s)", err, stdout.String(), stderr.String())
} }
return nil return stdout.Bytes(), nil
} }
func parseImage(image string) (repo string, tag string) { func parseImage(image string) (repo string, tag string) {
@@ -10,6 +10,11 @@ type Provisioner interface {
Provision(d Demo, resources []ExternalResource, cfg ProvisionConfig) error Provision(d Demo, resources []ExternalResource, cfg ProvisionConfig) error
// Teardown demande la destruction d'une démo. // Teardown demande la destruction d'une démo.
Teardown(d Demo) error Teardown(d Demo) error
// MigrateToPremiumNamespace reconstruit la démo dans newNamespace
// (Kubernetes ne permet pas de renommer un namespace) en préservant les
// données postgres, puis supprime l'ancien namespace une fois le
// nouveau opérationnel. No-op si d.Namespace == newNamespace.
MigrateToPremiumNamespace(d Demo, newNamespace, newURL string) error
GetResourceState(ctx context.Context, namespace string) (ResourceState, error) GetResourceState(ctx context.Context, namespace string) (ResourceState, error)
} }
@@ -19,6 +24,7 @@ type NoopProvisioner struct{}
func (NoopProvisioner) Provision(Demo, []ExternalResource, ProvisionConfig) error { return nil } func (NoopProvisioner) Provision(Demo, []ExternalResource, ProvisionConfig) error { return nil }
func (NoopProvisioner) Teardown(Demo) error { return nil } func (NoopProvisioner) Teardown(Demo) error { return nil }
func (NoopProvisioner) MigrateToPremiumNamespace(Demo, string, string) error { return nil }
func (NoopProvisioner) GetResourceState(ctx context.Context, namespace string) (ResourceState, error) { func (NoopProvisioner) GetResourceState(ctx context.Context, namespace string) (ResourceState, error) {
return ResourceState{}, nil return ResourceState{}, nil
} }
+42 -15
View File
@@ -75,15 +75,7 @@ func (s *Service) Create(username string, cfg ProvisionConfig) (Demo, error) {
CreatedAt: s.now().UTC(), CreatedAt: s.now().UTC(),
ExpiresAt: s.now().UTC().Add(s.cfg.TTL), ExpiresAt: s.now().UTC().Add(s.cfg.TTL),
} }
// Port omis dans l'URL s'il s'agit du 443 standard ; sinon affiché demo.URL = s.urlFor(demo.Namespace)
// explicitement (ex. NodePort HTTPS sans LoadBalancer). Ne concerne que
// l'URL affichée : le Host() de l'IngressRoute reste sans port (voir
// buildIngressRouteValues), le port n'y a pas sa place pour le matching.
portSuffix := ""
if s.cfg.HTTPSPort != "" && s.cfg.HTTPSPort != "443" {
portSuffix = ":" + s.cfg.HTTPSPort
}
demo.URL = "https://" + demo.Namespace + "." + s.cfg.BaseDomain + portSuffix
// Réserve les ressources externes (slots Telegram/NowPayments pré- // Réserve les ressources externes (slots Telegram/NowPayments pré-
// provisionnés) — contrainte réelle indépendante du nombre de démos, // provisionnés) — contrainte réelle indépendante du nombre de démos,
@@ -134,11 +126,16 @@ func (s *Service) ListForUser(username string) ([]Demo, error) {
} }
// TransferToPaid marque la démo active du client comme abonnement payant // TransferToPaid marque la démo active du client comme abonnement payant
// (n'expire plus). Le stockage (PVC postgres/redis) est déjà en place depuis // (n'expire plus) et déclenche la migration de son namespace vers
// la création de la démo (voir HelmProvisioner.Provision) : rien à basculer // "premium-<client>" (Kubernetes ne permet pas de renommer un namespace,
// côté infra, seul le statut change. No-op si le client n'a pas de démo // voir HelmProvisioner.MigrateToPremiumNamespace) — no-op si déjà migrée
// active — ce n'est pas une erreur (ex: compte créé sans jamais avoir // (ex. renouvellement d'un client déjà premium). Le stockage (PVC
// demandé de démo). // postgres/redis) est déjà en place depuis la création de la démo, seules
// les données postgres sont recopiées vers le nouveau namespace. No-op
// total si le client n'a pas de démo active — ce n'est pas une erreur (ex:
// compte créé sans jamais avoir demandé de démo). La migration est lente
// (dump/restore/helm) : elle tourne en tâche de fond, cette méthode ne
// bloque pas.
func (s *Service) TransferToPaid(username string) error { func (s *Service) TransferToPaid(username string) error {
existing, err := s.store.ListByUsername(auth.NormalizeUsername(username)) existing, err := s.store.ListByUsername(auth.NormalizeUsername(username))
if err != nil { if err != nil {
@@ -158,13 +155,43 @@ func (s *Service) TransferToPaid(username string) error {
active.TypeAbo = "premium" active.TypeAbo = "premium"
active.ExpiresAt = s.now().UTC().AddDate(10, 0, 0) // n'expire plus, en pratique active.ExpiresAt = s.now().UTC().AddDate(10, 0, 0) // n'expire plus, en pratique
if _, err := s.store.Update(*active); err != nil { updated, err := s.store.Update(*active)
if err != nil {
return err return err
} }
newNamespace := premiumNamespace(updated.Username)
if newNamespace != updated.Namespace {
newURL := s.urlFor(newNamespace)
go func() {
if err := s.prov.MigrateToPremiumNamespace(updated, newNamespace, newURL); err != nil {
log.Printf("Migration namespace premium échouée pour %s: %v", updated.Namespace, err)
return
}
updated.Namespace = newNamespace
updated.URL = newURL
if _, err := s.store.Update(updated); err != nil {
log.Printf("Mise à jour namespace après migration premium échouée pour %s: %v", newNamespace, err)
}
}()
}
return nil return nil
} }
// urlFor construit l'URL publique d'une démo pour un namespace donné. Port
// omis s'il s'agit du 443 standard ; sinon affiché explicitement (ex.
// NodePort HTTPS sans LoadBalancer). Ne concerne que l'URL affichée : le
// Host() de l'IngressRoute reste sans port (voir buildIngressRouteValues),
// le port n'y a pas sa place pour le matching.
func (s *Service) urlFor(namespace string) string {
portSuffix := ""
if s.cfg.HTTPSPort != "" && s.cfg.HTTPSPort != "443" {
portSuffix = ":" + s.cfg.HTTPSPort
}
return "https://" + namespace + "." + s.cfg.BaseDomain + portSuffix
}
// Delete déclenche le teardown et libère le pool. // Delete déclenche le teardown et libère le pool.
func (s *Service) Delete(id string) (Demo, error) { func (s *Service) Delete(id string) (Demo, error) {
d, ok := s.store.Get(id) d, ok := s.store.Get(id)
@@ -19,6 +19,8 @@ type Store interface {
UpdatePassword(id, passwordHahs string) (auth.User, error) UpdatePassword(id, passwordHahs string) (auth.User, error)
GetTelegram(id string) (auth.User, error) GetTelegram(id string) (auth.User, error)
SetTelegram(id, telegram string) (auth.User, error) SetTelegram(id, telegram string) (auth.User, error)
GetAlertSettings(id string) (auth.User, error)
SetAlertSettings(id string, discordWebhookURL, telegramBotToken, telegramChatID string) (auth.User, error)
} }
func (s *GormStore) UpdateUsername(id, newUsername string) (auth.User, error) { func (s *GormStore) UpdateUsername(id, newUsername string) (auth.User, error) {
@@ -71,3 +73,36 @@ func (s *GormStore) SetTelegram(id, telegram string) (auth.User, error) {
} }
return user, nil return user, nil
} }
func (s *GormStore) GetAlertSettings(id string) (auth.User, error) {
var user auth.User
if err := s.db.First(&user, "id = ?", id).Error; err != nil {
return auth.User{}, err
}
return user, nil
}
// SetAlertSettings enregistre les réglages d'alerte de cet admin. Une chaîne
// vide efface le champ correspondant (désactive ce canal pour cet admin).
func (s *GormStore) SetAlertSettings(id string, discordWebhookURL, telegramBotToken, telegramChatID string) (auth.User, error) {
updates := map[string]interface{}{
"alert_discord_webhook_url": nullIfEmpty(discordWebhookURL),
"alert_telegram_bot_token": nullIfEmpty(telegramBotToken),
"alert_telegram_chat_id": nullIfEmpty(telegramChatID),
}
if err := s.db.Model(&auth.User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return auth.User{}, err
}
var user auth.User
if err := s.db.First(&user, "id = ?", id).Error; err != nil {
return auth.User{}, err
}
return user, nil
}
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}
@@ -27,6 +27,32 @@ type newTelegram struct {
Telegram string `json:"telegram" binding:"required,min=3,max=64"` Telegram string `json:"telegram" binding:"required,min=3,max=64"`
} }
type alertSettingsRequest struct {
DiscordWebhookURL string `json:"discord_webhook_url" binding:"omitempty,max=500,url"`
TelegramBotToken string `json:"telegram_bot_token" binding:"omitempty,max=200"`
TelegramChatID string `json:"telegram_chat_id" binding:"omitempty,max=64"`
}
type alertSettingsResponse struct {
DiscordWebhookURL string `json:"discord_webhook_url"`
TelegramBotToken string `json:"telegram_bot_token"`
TelegramChatID string `json:"telegram_chat_id"`
}
func toAlertSettingsResponse(user auth.User) alertSettingsResponse {
deref := func(s *string) string {
if s == nil {
return ""
}
return *s
}
return alertSettingsResponse{
DiscordWebhookURL: deref(user.AlertDiscordWebhookURL),
TelegramBotToken: deref(user.AlertTelegramBotToken),
TelegramChatID: deref(user.AlertTelegramChatID),
}
}
func (h *Handler) UpdateUsernameById(c *gin.Context) { func (h *Handler) UpdateUsernameById(c *gin.Context) {
var u newUsername var u newUsername
@@ -110,3 +136,40 @@ func (h *Handler) SetTelegramById(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"telegram": user.Telegram}) c.JSON(http.StatusOK, gin.H{"telegram": user.Telegram})
} }
// GetAlerts : réglages d'alerte monitoring (webhook Discord / bot Telegram)
// de l'admin authentifié — chacun configure les siens, rien de partagé.
func (h *Handler) GetAlerts(c *gin.Context) {
p := auth.PrincipalFrom(c)
if p == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
return
}
user, err := h.store.GetAlertSettings(p.UserID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return
}
c.JSON(http.StatusOK, toAlertSettingsResponse(user))
}
// SetAlerts : enregistre les réglages d'alerte de l'admin authentifié. Un
// champ vide désactive le canal correspondant.
func (h *Handler) SetAlerts(c *gin.Context) {
var req alertSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return
}
p := auth.PrincipalFrom(c)
if p == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "non authentifié"})
return
}
user, err := h.store.SetAlertSettings(p.UserID, req.DiscordWebhookURL, req.TelegramBotToken, req.TelegramChatID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "erreur serveur"})
return
}
c.JSON(http.StatusOK, toAlertSettingsResponse(user))
}
@@ -68,6 +68,8 @@ func New(d Deps) *gin.Engine {
{ {
admin.GET("/codes", d.SubH.ListCodes) admin.GET("/codes", d.SubH.ListCodes)
admin.POST("/codes", d.SubH.CreateCodeForBuy) admin.POST("/codes", d.SubH.CreateCodeForBuy)
admin.GET("/profile/alerts", d.ProfileH.GetAlerts)
admin.POST("/profile/alerts", d.ProfileH.SetAlerts)
if d.DemosH != nil { if d.DemosH != nil {
admin.POST("/demos", d.DemosH.Create) admin.POST("/demos", d.DemosH.Create)
admin.GET("/demos", d.DemosH.List) admin.GET("/demos", d.DemosH.List)
+1510
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Omnex — Plateforme de gestion de commandes & livraison</title> <title>Omnex — Plateforme de gestion de commandes & livraison</title>
<meta name="description" content="Déployez en un clic une démo complète de la plateforme de gestion de commandes et de livraison." /> <meta name="description" content="Déployez en un clic une démo complète de la plateforme de gestion de commandes et de livraison." />
<script type="module" crossorigin src="/assets/index-DRDLx_J5.js"></script> <script type="module" crossorigin src="/assets/index-BZ4jKugg.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+70 -10
View File
@@ -12,6 +12,9 @@
"@chakra-ui/react": "^2.10.4", "@chakra-ui/react": "^2.10.4",
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^3.5.0",
"@saas-ui/react": "^2.11.0", "@saas-ui/react": "^2.11.0",
"framer-motion": "^11.11.0", "framer-motion": "^11.11.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -89,6 +92,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.7", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7", "@babel/generator": "^7.29.7",
@@ -382,6 +386,7 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-2.10.10.tgz", "resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-2.10.10.tgz",
"integrity": "sha512-uahxAfb83/O9fcgBm3ew/nVeQiNgE0uQf8NdQQp3I6u/PCuR2OjSEneV4Ba86W8z50Ppwcxvf9XDk5btYceggQ==", "integrity": "sha512-uahxAfb83/O9fcgBm3ew/nVeQiNgE0uQf8NdQQp3I6u/PCuR2OjSEneV4Ba86W8z50Ppwcxvf9XDk5btYceggQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@chakra-ui/hooks": "2.4.6", "@chakra-ui/hooks": "2.4.6",
"@chakra-ui/styled-system": "2.12.5", "@chakra-ui/styled-system": "2.12.5",
@@ -407,6 +412,7 @@
"resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.12.5.tgz", "resolved": "https://registry.npmjs.org/@chakra-ui/styled-system/-/styled-system-2.12.5.tgz",
"integrity": "sha512-sy39LJWnOvGcOHYBXQxT3bC5zj+R7GVUgxzkkoqx+auV5KVKOOE5aZD+POsciWEt86bqdEa+LpFm+E5pcdbkyg==", "integrity": "sha512-sy39LJWnOvGcOHYBXQxT3bC5zj+R7GVUgxzkkoqx+auV5KVKOOE5aZD+POsciWEt86bqdEa+LpFm+E5pcdbkyg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@chakra-ui/utils": "2.2.6", "@chakra-ui/utils": "2.2.6",
"csstype": "^3.1.2" "csstype": "^3.1.2"
@@ -541,6 +547,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -564,6 +571,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -626,6 +634,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
@@ -669,6 +678,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.3", "@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5", "@emotion/babel-plugin": "^11.13.5",
@@ -1105,6 +1115,53 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.1.tgz",
"integrity": "sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.3.1.tgz",
"integrity": "sha512-BoxVN3PKnMbgStHhjoaky/oWdxHomDqmBVA24IA3KEmssFGeI7u9YT/BJceOjIum/t6TpPa/vcMKaVYQeIQ/3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.3.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.1.tgz",
"integrity": "sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg==",
"license": "(CC-BY-4.0 AND MIT)",
"dependencies": {
"@fortawesome/fontawesome-common-types": "7.3.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.5.0.tgz",
"integrity": "sha512-63mlRr6fiBbJ0wjr1Cf6dsDGtP2lNvk9lnatKgxs/fIkhslsZT291hIUzJkuUkI9yr69ZvWnWfgb2qXm4QyVaA==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~6 || ~7",
"react": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@hookform/resolvers": { "node_modules/@hookform/resolvers": {
"version": "3.10.0", "version": "3.10.0",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz",
@@ -1739,6 +1796,7 @@
"resolved": "https://registry.npmjs.org/@saas-ui/react/-/react-2.11.4.tgz", "resolved": "https://registry.npmjs.org/@saas-ui/react/-/react-2.11.4.tgz",
"integrity": "sha512-rSit+RrsV1xkrli4tne1TjkEjGbkyMefNhP34iNfjbrWMuyu1F/csRx8F6tarYq7NRFmYbF6AnTY6ijgDQ2WBQ==", "integrity": "sha512-rSit+RrsV1xkrli4tne1TjkEjGbkyMefNhP34iNfjbrWMuyu1F/csRx8F6tarYq7NRFmYbF6AnTY6ijgDQ2WBQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@chakra-ui/utils": "^2.2.3", "@chakra-ui/utils": "^2.2.3",
"@saas-ui/core": "2.8.1", "@saas-ui/core": "2.8.1",
@@ -1948,8 +2006,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -2037,6 +2094,7 @@
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -2048,6 +2106,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
@@ -2288,7 +2347,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -2299,7 +2357,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -2394,6 +2451,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.44", "baseline-browser-mapping": "^2.10.44",
"caniuse-lite": "^1.0.30001806", "caniuse-lite": "^1.0.30001806",
@@ -2661,8 +2719,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
@@ -2884,6 +2941,7 @@
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
"integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"motion-dom": "^11.18.1", "motion-dom": "^11.18.1",
"motion-utils": "^11.18.1", "motion-utils": "^11.18.1",
@@ -3186,6 +3244,7 @@
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssstyle": "^4.1.0", "cssstyle": "^4.1.0",
"data-urls": "^5.0.0", "data-urls": "^5.0.0",
@@ -3299,7 +3358,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -3539,7 +3597,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -3554,8 +3611,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
@@ -3583,6 +3639,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -3628,6 +3685,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -3670,6 +3728,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.82.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.82.0.tgz",
"integrity": "sha512-Zw/uFZ2dO+02GHlBn7JFGn8kZJ7LdM33B/0BXOovzFay+CMhf94JMw5BVu+F1tVkUKjNvBuaE3fz5BJhga10Tg==", "integrity": "sha512-Zw/uFZ2dO+02GHlBn7JFGn8kZJ7LdM33B/0BXOovzFay+CMhf94JMw5BVu+F1tVkUKjNvBuaE3fz5BJhga10Tg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -4233,6 +4292,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
+3
View File
@@ -15,6 +15,9 @@
"@chakra-ui/react": "^2.10.4", "@chakra-ui/react": "^2.10.4",
"@emotion/react": "^11.13.3", "@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0", "@emotion/styled": "^11.13.0",
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-solid-svg-icons": "^7.3.1",
"@fortawesome/react-fontawesome": "^3.5.0",
"@saas-ui/react": "^2.11.0", "@saas-ui/react": "^2.11.0",
"framer-motion": "^11.11.0", "framer-motion": "^11.11.0",
"react": "^18.3.1", "react": "^18.3.1",
+11 -2
View File
@@ -1,8 +1,9 @@
import { Navigate, Route, Routes } from 'react-router-dom' import { Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { Center, Spinner } from '@chakra-ui/react' import { Center, Spinner } from '@chakra-ui/react'
import { Landing } from './pages/Landing' import { Landing } from './pages/Landing'
import { Pricing } from './pages/Pricing' import { Pricing } from './pages/Pricing'
import { Login } from './pages/Login' import { Login } from './pages/Login'
import { AdminLogin } from './pages/AdminLogin'
import { Register } from './pages/Register' import { Register } from './pages/Register'
import { Demos } from './pages/backoffice/Demos' import { Demos } from './pages/backoffice/Demos'
import { PremiumDemos } from './pages/backoffice/PremiumDemos' import { PremiumDemos } from './pages/backoffice/PremiumDemos'
@@ -14,8 +15,13 @@ import { BackofficeLayout } from './components/BackofficeLayout'
import { useAuth } from './lib/auth' import { useAuth } from './lib/auth'
import type { JSX } from 'react' import type { JSX } from 'react'
// Chemins admin uniquement : un accès non authentifié y renvoie vers la
// page de connexion admin plutôt que la page client.
const ADMIN_ONLY_PATHS = ['/app/demos', '/app/premium', '/app/codes']
function RequireAuth({ children }: { children: JSX.Element }) { function RequireAuth({ children }: { children: JSX.Element }) {
const { isAuthenticated, initializing } = useAuth() const { isAuthenticated, initializing } = useAuth()
const location = useLocation()
if (initializing) { if (initializing) {
return ( return (
<Center h="100vh"> <Center h="100vh">
@@ -23,7 +29,9 @@ function RequireAuth({ children }: { children: JSX.Element }) {
</Center> </Center>
) )
} }
return isAuthenticated ? children : <Navigate to="/login" replace /> if (isAuthenticated) return children
const isAdminPath = ADMIN_ONLY_PATHS.some((p) => location.pathname.startsWith(p))
return <Navigate to={isAdminPath ? '/admin/login' : '/login'} replace />
} }
// Réservé à l'admin : provisioning des démos. // Réservé à l'admin : provisioning des démos.
@@ -52,6 +60,7 @@ export function App() {
<Route path="/tarifs" element={<Pricing />} /> <Route path="/tarifs" element={<Pricing />} />
</Route> </Route>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/admin/login" element={<AdminLogin />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
{/* Back-office protégé */} {/* Back-office protégé */}
+18 -1
View File
@@ -28,14 +28,17 @@ const HamburgerIcon = () => (
</Box> </Box>
) )
const SUPPORT_TELEGRAM_URL = 'https://t.me/OMNEX_CORP'
export function BackofficeLayout() { export function BackofficeLayout() {
const { logout, isAdmin, isClient } = useAuth() const { logout, isAdmin, isClient } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const onLogout = async () => { const onLogout = async () => {
const loginPath = isAdmin ? '/admin/login' : '/login'
await logout() await logout()
navigate('/login', { replace: true }) navigate(loginPath, { replace: true })
} }
const roleLabel = isAdmin ? 'Admin' : isClient ? 'Client' : 'Utilisateur' const roleLabel = isAdmin ? 'Admin' : isClient ? 'Client' : 'Utilisateur'
@@ -57,6 +60,9 @@ export function BackofficeLayout() {
<Spacer /> <Spacer />
<HStack spacing={2} display={{ base: 'none', md: 'flex' }}> <HStack spacing={2} display={{ base: 'none', md: 'flex' }}>
<Badge colorScheme={roleColor}>{roleLabel}</Badge> <Badge colorScheme={roleColor}>{roleLabel}</Badge>
<Button as="a" href={SUPPORT_TELEGRAM_URL} target="_blank" rel="noopener noreferrer" size="sm" variant="outline">
Support
</Button>
<ColorModeToggle /> <ColorModeToggle />
<Button size="sm" variant="outline" onClick={onLogout}> <Button size="sm" variant="outline" onClick={onLogout}>
Déconnexion Déconnexion
@@ -115,6 +121,17 @@ export function BackofficeLayout() {
<Divider my={6} /> <Divider my={6} />
<Stack spacing={3}> <Stack spacing={3}>
<Button
as="a"
href={SUPPORT_TELEGRAM_URL}
target="_blank"
rel="noopener noreferrer"
variant="outline"
justifyContent="flex-start"
onClick={onClose}
>
Support
</Button>
<Button variant="outline" justifyContent="flex-start" onClick={onLogout}> <Button variant="outline" justifyContent="flex-start" onClick={onLogout}>
Déconnexion Déconnexion
</Button> </Button>
+7 -12
View File
@@ -21,6 +21,7 @@ import {
useToast, useToast,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { api, ApiError, type CreateDemoParams, type StorageDriver } from '../lib/api' import { api, ApiError, type CreateDemoParams, type StorageDriver } from '../lib/api'
import { PasswordInput } from './PasswordInput'
interface CreateDemoModalProps { interface CreateDemoModalProps {
isOpen: boolean isOpen: boolean
@@ -179,11 +180,10 @@ export function CreateDemoModal({ isOpen, onClose, onCreated }: CreateDemoModalP
</FormControl> </FormControl>
<FormControl isRequired isDisabled={submitting}> <FormControl isRequired isDisabled={submitting}>
<FormLabel fontSize="sm">Mot de passe</FormLabel> <FormLabel fontSize="sm">Mot de passe</FormLabel>
<Input <PasswordInput
placeholder="8 caractères min." placeholder="8 caractères min."
value={adminPassword} value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)} onChange={(e) => setAdminPassword(e.target.value)}
type="password"
autoComplete="off" autoComplete="off"
/> />
</FormControl> </FormControl>
@@ -212,11 +212,10 @@ export function CreateDemoModal({ isOpen, onClose, onCreated }: CreateDemoModalP
</FormControl> </FormControl>
<FormControl isDisabled={submitting}> <FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Token bot Telegram</FormLabel> <FormLabel fontSize="sm">Token bot Telegram</FormLabel>
<Input <PasswordInput
placeholder="123456:ABC-DEF..." placeholder="123456:ABC-DEF..."
value={telegramBotToken} value={telegramBotToken}
onChange={(e) => setTelegramBotToken(e.target.value)} onChange={(e) => setTelegramBotToken(e.target.value)}
type="password"
autoComplete="off" autoComplete="off"
/> />
</FormControl> </FormControl>
@@ -237,21 +236,19 @@ export function CreateDemoModal({ isOpen, onClose, onCreated }: CreateDemoModalP
<Stack spacing={3} pl={3} borderLeftWidth="2px" borderColor="primary.500"> <Stack spacing={3} pl={3} borderLeftWidth="2px" borderColor="primary.500">
<FormControl isDisabled={submitting}> <FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Clé API NowPayments</FormLabel> <FormLabel fontSize="sm">Clé API NowPayments</FormLabel>
<Input <PasswordInput
placeholder="clé API du compte marchand" placeholder="clé API du compte marchand"
value={nowPaymentsApiKey} value={nowPaymentsApiKey}
onChange={(e) => setNowPaymentsApiKey(e.target.value)} onChange={(e) => setNowPaymentsApiKey(e.target.value)}
type="password"
autoComplete="off" autoComplete="off"
/> />
</FormControl> </FormControl>
<FormControl isDisabled={submitting}> <FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Secret IPN NowPayments</FormLabel> <FormLabel fontSize="sm">Secret IPN NowPayments</FormLabel>
<Input <PasswordInput
placeholder="secret configuré côté NowPayments" placeholder="secret configuré côté NowPayments"
value={nowPaymentsIpnSecret} value={nowPaymentsIpnSecret}
onChange={(e) => setNowPaymentsIpnSecret(e.target.value)} onChange={(e) => setNowPaymentsIpnSecret(e.target.value)}
type="password"
autoComplete="off" autoComplete="off"
/> />
<Text fontSize="xs" color="gray.500" mt={1}> <Text fontSize="xs" color="gray.500" mt={1}>
@@ -287,11 +284,10 @@ export function CreateDemoModal({ isOpen, onClose, onCreated }: CreateDemoModalP
</FormControl> </FormControl>
<FormControl isDisabled={submitting}> <FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Bot 1 token</FormLabel> <FormLabel fontSize="sm">Bot 1 token</FormLabel>
<Input <PasswordInput
placeholder="123456:ABC-DEF..." placeholder="123456:ABC-DEF..."
value={lbBot1Token} value={lbBot1Token}
onChange={(e) => setLbBot1Token(e.target.value)} onChange={(e) => setLbBot1Token(e.target.value)}
type="password"
autoComplete="off" autoComplete="off"
/> />
</FormControl> </FormControl>
@@ -307,11 +303,10 @@ export function CreateDemoModal({ isOpen, onClose, onCreated }: CreateDemoModalP
</FormControl> </FormControl>
<FormControl isDisabled={submitting}> <FormControl isDisabled={submitting}>
<FormLabel fontSize="sm">Bot 2 token</FormLabel> <FormLabel fontSize="sm">Bot 2 token</FormLabel>
<Input <PasswordInput
placeholder="123456:ABC-DEF..." placeholder="123456:ABC-DEF..."
value={lbBot2Token} value={lbBot2Token}
onChange={(e) => setLbBot2Token(e.target.value)} onChange={(e) => setLbBot2Token(e.target.value)}
type="password"
autoComplete="off" autoComplete="off"
/> />
</FormControl> </FormControl>
+2 -2
View File
@@ -62,7 +62,7 @@ export function Header() {
<HStack spacing={2} display={{ base: 'none', md: 'flex' }}> <HStack spacing={2} display={{ base: 'none', md: 'flex' }}>
<ColorModeToggle /> <ColorModeToggle />
<Button as={RouterLink} to="/login" variant="ghost" size="sm"> <Button as={RouterLink} to="/login" variant="ghost" size="sm">
Espace commercial Espace client
</Button> </Button>
<Button as={RouterLink} to="/register" colorScheme="primary" size="sm"> <Button as={RouterLink} to="/register" colorScheme="primary" size="sm">
Créer un compte Créer un compte
@@ -109,7 +109,7 @@ export function Header() {
justifyContent="flex-start" justifyContent="flex-start"
onClick={onClose} onClick={onClose}
> >
Espace commercial Espace client
</Button> </Button>
<Button <Button
as={RouterLink} as={RouterLink}
+26
View File
@@ -0,0 +1,26 @@
import { forwardRef, useState } from 'react'
import { IconButton, Input, InputGroup, InputRightElement, type InputProps } from '@chakra-ui/react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'
// Input mot de passe avec bascule affichage clair/masqué (icône Font Awesome).
export const PasswordInput = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
const [visible, setVisible] = useState(false)
return (
<InputGroup>
<Input ref={ref} type={visible ? 'text' : 'password'} {...props} />
<InputRightElement>
<IconButton
aria-label={visible ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
icon={<FontAwesomeIcon icon={visible ? faEyeSlash : faEye} />}
size="sm"
variant="ghost"
tabIndex={-1}
onClick={() => setVisible((v) => !v)}
/>
</InputRightElement>
</InputGroup>
)
})
PasswordInput.displayName = 'PasswordInput'
+11 -1
View File
@@ -133,13 +133,20 @@ export interface TelegramInfo {
telegram: string telegram: string
} }
export interface AlertSettings {
discord_webhook_url: string
telegram_bot_token: string
telegram_chat_id: string
}
// --- Endpoints --- // --- Endpoints ---
export const api = { export const api = {
login: (username: string, password: string) => login: (username: string, password: string, role: Role) =>
request<{ token: string; token_type: string; role: Role }>('POST', '/auth/login', { request<{ token: string; token_type: string; role: Role }>('POST', '/auth/login', {
username, username,
password, password,
role,
}), }),
register: (username: string, password: string) => register: (username: string, password: string) =>
request<{ token: string; token_type: string; role: Role }>('POST', '/auth/register', { request<{ token: string; token_type: string; role: Role }>('POST', '/auth/register', {
@@ -197,4 +204,7 @@ export const api = {
getTelegram: () => request<TelegramInfo>('GET', '/profile/telegram'), getTelegram: () => request<TelegramInfo>('GET', '/profile/telegram'),
setTelegram: (telegram: string) => setTelegram: (telegram: string) =>
request<TelegramInfo>('POST', '/profile/telegram', { telegram }), request<TelegramInfo>('POST', '/profile/telegram', { telegram }),
getAlertSettings: () => request<AlertSettings>('GET', '/profile/alerts'),
setAlertSettings: (settings: AlertSettings) =>
request<AlertSettings>('POST', '/profile/alerts', settings),
} }
+3 -3
View File
@@ -17,7 +17,7 @@ interface AuthState {
role: Role | null role: Role | null
typeAbo: string | null typeAbo: string | null
initializing: boolean initializing: boolean
login: (username: string, password: string) => Promise<void> login: (username: string, password: string, role: Role) => Promise<void>
register: (username: string, password: string) => Promise<void> register: (username: string, password: string) => Promise<void>
logout: () => Promise<void> logout: () => Promise<void>
refreshAbo: () => Promise<void> refreshAbo: () => Promise<void>
@@ -62,8 +62,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
const login = useCallback(async (username: string, password: string) => { const login = useCallback(async (username: string, password: string, role: Role) => {
const res = await api.login(username, password) const res = await api.login(username, password, role)
setToken(res.token) setToken(res.token)
setTok(res.token) setTok(res.token)
setRole(res.role) setRole(res.role)
+92
View File
@@ -0,0 +1,92 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
CardBody,
Container,
FormControl,
FormLabel,
Heading,
Input,
Stack,
Text,
useToast,
} from '@chakra-ui/react'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { useAuth } from '../lib/auth'
import { ApiError } from '../lib/api'
import { ColorModeToggle } from '../components/ColorModeToggle'
import { PasswordInput } from '../components/PasswordInput'
// Page de connexion admin, séparée de la page client (/login) : identifiants
// admin et client ne sont pas interchangeables entre les deux pages (voir
// contrôle du rôle côté backend, auth.Handler.Login). Pas de page
// d'inscription pour l'admin — les comptes admin sont provisionnés
// directement (seed), jamais auto-créés.
export function AdminLogin() {
const { login } = useAuth()
const navigate = useNavigate()
const toast = useToast()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
await login(username.trim(), password, 'admin')
navigate('/app', { replace: true })
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Connexion impossible'
toast({ status: 'error', title: 'Échec de connexion', description: msg })
} finally {
setLoading(false)
}
}
return (
<Container maxW="sm" py={20} position="relative">
<Box position="absolute" top={4} right={4}>
<ColorModeToggle />
</Box>
<Stack spacing={6}>
<Box textAlign="center">
<Heading size="lg">Espace admin</Heading>
<Text color="gray.500">Connectez-vous pour administrer la plateforme.</Text>
</Box>
<Card>
<CardBody>
<form onSubmit={onSubmit}>
<Stack spacing={4}>
<FormControl isRequired>
<FormLabel>Nom d'utilisateur</FormLabel>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Mot de passe</FormLabel>
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</FormControl>
<Button type="submit" colorScheme="primary" isLoading={loading}>
Se connecter
</Button>
</Stack>
</form>
</CardBody>
</Card>
<Button as={RouterLink} to="/" variant="link" size="sm">
Retour au site
</Button>
</Stack>
</Container>
)
}
+5 -5
View File
@@ -18,6 +18,7 @@ import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { useAuth } from '../lib/auth' import { useAuth } from '../lib/auth'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { ColorModeToggle } from '../components/ColorModeToggle' import { ColorModeToggle } from '../components/ColorModeToggle'
import { PasswordInput } from '../components/PasswordInput'
export function Login() { export function Login() {
const { login } = useAuth() const { login } = useAuth()
@@ -31,7 +32,7 @@ export function Login() {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
try { try {
await login(username.trim(), password) await login(username.trim(), password, 'client')
navigate('/app', { replace: true }) navigate('/app', { replace: true })
} catch (err) { } catch (err) {
const msg = err instanceof ApiError ? err.message : 'Connexion impossible' const msg = err instanceof ApiError ? err.message : 'Connexion impossible'
@@ -48,8 +49,8 @@ export function Login() {
</Box> </Box>
<Stack spacing={6}> <Stack spacing={6}>
<Box textAlign="center"> <Box textAlign="center">
<Heading size="lg">Espace commercial</Heading> <Heading size="lg">Espace client</Heading>
<Text color="gray.500">Connectez-vous pour gérer les démos.</Text> <Text color="gray.500">Connectez-vous pour gérer votre abonnement.</Text>
</Box> </Box>
<Card> <Card>
<CardBody> <CardBody>
@@ -65,8 +66,7 @@ export function Login() {
</FormControl> </FormControl>
<FormControl isRequired> <FormControl isRequired>
<FormLabel>Mot de passe</FormLabel> <FormLabel>Mot de passe</FormLabel>
<Input <PasswordInput
type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" autoComplete="current-password"
+3 -4
View File
@@ -19,6 +19,7 @@ import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { useAuth } from '../lib/auth' import { useAuth } from '../lib/auth'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { ColorModeToggle } from '../components/ColorModeToggle' import { ColorModeToggle } from '../components/ColorModeToggle'
import { PasswordInput } from '../components/PasswordInput'
export function Register() { export function Register() {
const { register } = useAuth() const { register } = useAuth()
@@ -76,8 +77,7 @@ export function Register() {
<FormControl isRequired isInvalid={password.length > 0 && !passwordValid}> <FormControl isRequired isInvalid={password.length > 0 && !passwordValid}>
<FormLabel>Mot de passe</FormLabel> <FormLabel>Mot de passe</FormLabel>
<Input <PasswordInput
type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
@@ -87,8 +87,7 @@ export function Register() {
<FormControl isRequired isInvalid={confirm.length > 0 && !passwordsMatch}> <FormControl isRequired isInvalid={confirm.length > 0 && !passwordsMatch}>
<FormLabel>Confirmer le mot de passe</FormLabel> <FormLabel>Confirmer le mot de passe</FormLabel>
<Input <PasswordInput
type="password"
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
autoComplete="new-password" autoComplete="new-password"
+1 -1
View File
@@ -43,7 +43,7 @@ export function Codes() {
const res = await api.listCodes() const res = await api.listCodes()
setCodes(res.items ?? []) setCodes(res.items ?? [])
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/login') if (err instanceof ApiError && err.status === 401) navigate('/admin/login')
else toast({ status: 'error', title: 'Chargement des codes impossible' }) else toast({ status: 'error', title: 'Chargement des codes impossible' })
} finally { } finally {
setLoading(false) setLoading(false)
+1 -1
View File
@@ -55,7 +55,7 @@ export function Demos() {
// les mélanger avec les démos d'essai ici. // les mélanger avec les démos d'essai ici.
setDemos((res.items ?? []).filter((d) => d.type_abonnement !== 'premium')) setDemos((res.items ?? []).filter((d) => d.type_abonnement !== 'premium'))
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/login') if (err instanceof ApiError && err.status === 401) navigate('/admin/login')
else toast({ status: 'error', title: 'Chargement des démos impossible' }) else toast({ status: 'error', title: 'Chargement des démos impossible' })
} finally { } finally {
setLoading(false) setLoading(false)
+21 -4
View File
@@ -2,11 +2,14 @@ import { Fragment, useCallback, useEffect, useState } from 'react'
import { import {
Badge, Badge,
Box, Box,
Button,
Collapse, Collapse,
Flex,
Heading, Heading,
HStack, HStack,
Icon, Icon,
Link, Link,
Spacer,
Spinner, Spinner,
Table, Table,
TableContainer, TableContainer,
@@ -22,6 +25,7 @@ import { useNavigate } from 'react-router-dom'
import { api, ApiError, type Demo, type DemoDetails } from '../../lib/api' import { api, ApiError, type Demo, type DemoDetails } from '../../lib/api'
import { statusColor, statusLabel } from '../../lib/format' import { statusColor, statusLabel } from '../../lib/format'
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel' import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
import { CreateDemoModal } from '../../components/CreateDemoModal'
// Un provisioning en cours => on rafraîchit régulièrement. // Un provisioning en cours => on rafraîchit régulièrement.
const POLL_MS = 5000 const POLL_MS = 5000
@@ -33,6 +37,7 @@ export function PremiumDemos() {
const navigate = useNavigate() const navigate = useNavigate()
const [demos, setDemos] = useState<Demo[]>([]) const [demos, setDemos] = useState<Demo[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [createModalOpen, setCreateModalOpen] = useState(false)
// --- Ligne dépliée (état live des pods) --- // --- Ligne dépliée (état live des pods) ---
const [expandedId, setExpandedId] = useState<string | null>(null) const [expandedId, setExpandedId] = useState<string | null>(null)
@@ -44,7 +49,7 @@ export function PremiumDemos() {
const res = await api.listDemos() const res = await api.listDemos()
setDemos((res.items ?? []).filter((d) => d.type_abonnement === 'premium')) setDemos((res.items ?? []).filter((d) => d.type_abonnement === 'premium'))
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/login') if (err instanceof ApiError && err.status === 401) navigate('/admin/login')
else toast({ status: 'error', title: 'Chargement des démos impossible' }) else toast({ status: 'error', title: 'Chargement des démos impossible' })
} finally { } finally {
setLoading(false) setLoading(false)
@@ -98,13 +103,25 @@ export function PremiumDemos() {
return ( return (
<> <>
<Heading size="md" mb={2}> <Flex mb={2} align="center" gap={4} wrap="wrap">
Démos Premium <Heading size="md" mr={4}>
</Heading> Démos Premium
</Heading>
<Spacer />
<Button colorScheme="primary" onClick={() => setCreateModalOpen(true)}>
Déployer une plateforme
</Button>
</Flex>
<Text color="gray.500" mb={6} fontSize="sm"> <Text color="gray.500" mb={6} fontSize="sm">
Démos rattachées à un client passé en abonnement payant stockage persistant, n'expirent plus. Démos rattachées à un client passé en abonnement payant stockage persistant, n'expirent plus.
</Text> </Text>
<CreateDemoModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
onCreated={() => void load()}
/>
{loading ? ( {loading ? (
<Spinner /> <Spinner />
) : demos.length === 0 ? ( ) : demos.length === 0 ? (
+128 -4
View File
@@ -11,9 +11,13 @@ import {
EditableInput, EditableInput,
EditablePreview, EditablePreview,
Flex, Flex,
FormControl,
FormHelperText,
FormLabel,
Heading, Heading,
HStack, HStack,
IconButton, IconButton,
Input,
SimpleGrid, SimpleGrid,
Spinner, Spinner,
Stack, Stack,
@@ -25,6 +29,8 @@ import {
useToast, useToast,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { CheckIcon, CloseIcon, EditIcon } from '@chakra-ui/icons' import { CheckIcon, CloseIcon, EditIcon } from '@chakra-ui/icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { api, ApiError } from '../../lib/api' import { api, ApiError } from '../../lib/api'
import { useAuth } from '../../lib/auth' import { useAuth } from '../../lib/auth'
@@ -101,18 +107,30 @@ export function Profile() {
const [updatingUsername, setUpdatingUsername] = useState(false) const [updatingUsername, setUpdatingUsername] = useState(false)
const [updatingPassword, setUpdatingPassword] = useState(false) const [updatingPassword, setUpdatingPassword] = useState(false)
const [passwordVersion, setPasswordVersion] = useState(0) const [passwordVersion, setPasswordVersion] = useState(0)
const [passwordVisible, setPasswordVisible] = useState(false)
const [telegram, setTelegramState] = useState('') const [telegram, setTelegramState] = useState('')
const [updatingTelegram, setUpdatingTelegram] = useState(false) const [updatingTelegram, setUpdatingTelegram] = useState(false)
const [addingTelegram, setAddingTelegram] = useState(false) const [addingTelegram, setAddingTelegram] = useState(false)
const [discordWebhookUrl, setDiscordWebhookUrl] = useState('')
const [telegramBotToken, setTelegramBotToken] = useState('')
const [telegramChatId, setTelegramChatId] = useState('')
const [savingAlerts, setSavingAlerts] = useState(false)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
;(async () => { ;(async () => {
try { try {
const [meRes, tgRes] = await Promise.all([api.me(), api.getTelegram()]) const [meRes, tgRes] = await Promise.all([api.me(), api.getTelegram()])
if (!cancelled) { if (cancelled) return
setMe(meRes) setMe(meRes)
setTelegramState(tgRes.telegram ?? '') setTelegramState(tgRes.telegram ?? '')
if (meRes.role === 'admin') {
const alertsRes = await api.getAlertSettings()
if (cancelled) return
setDiscordWebhookUrl(alertsRes.discord_webhook_url)
setTelegramBotToken(alertsRes.telegram_bot_token)
setTelegramChatId(alertsRes.telegram_chat_id)
} }
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 401) { if (err instanceof ApiError && err.status === 401) {
@@ -127,6 +145,33 @@ export function Profile() {
return () => { cancelled = true } return () => { cancelled = true }
}, [navigate, toast]) }, [navigate, toast])
const handleSaveAlertSettings = async () => {
setSavingAlerts(true)
try {
const res = await api.setAlertSettings({
discord_webhook_url: discordWebhookUrl.trim(),
telegram_bot_token: telegramBotToken.trim(),
telegram_chat_id: telegramChatId.trim(),
})
setDiscordWebhookUrl(res.discord_webhook_url)
setTelegramBotToken(res.telegram_bot_token)
setTelegramChatId(res.telegram_chat_id)
toast({ status: 'success', title: 'Alertes enregistrées' })
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
navigate('/login')
return
}
toast({
status: 'error',
title: "Impossible d'enregistrer les alertes",
description: err instanceof ApiError ? err.message : undefined,
})
} finally {
setSavingAlerts(false)
}
}
const handleUpdateUsername = async (newUsername: string) => { const handleUpdateUsername = async (newUsername: string) => {
const trimmed = newUsername.trim() const trimmed = newUsername.trim()
if (!me || !trimmed || trimmed === me.username) return if (!me || !trimmed || trimmed === me.username) return
@@ -310,7 +355,19 @@ export function Profile() {
> >
<HStack spacing={2}> <HStack spacing={2}>
<EditablePreview as={StatNumber} fontSize="md" fontFamily="mono" /> <EditablePreview as={StatNumber} fontSize="md" fontFamily="mono" />
<EditableInput type="password" fontSize="md" fontFamily="mono" /> <EditableInput
type={passwordVisible ? 'text' : 'password'}
fontSize="md"
fontFamily="mono"
/>
<IconButton
aria-label={passwordVisible ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
icon={<FontAwesomeIcon icon={passwordVisible ? faEyeSlash : faEye} />}
size="sm"
variant="ghost"
tabIndex={-1}
onClick={() => setPasswordVisible((v) => !v)}
/>
<EditableUsernameControls /> <EditableUsernameControls />
</HStack> </HStack>
</Editable> </Editable>
@@ -359,6 +416,73 @@ export function Profile() {
</Stat> </Stat>
</SimpleGrid> </SimpleGrid>
{me.role === 'admin' && (
<>
<Divider borderColor="chakra-border-color" />
<Stack spacing={4}>
<Box>
<Heading size="sm">Alertes monitoring</Heading>
<Text color="gray.500" fontSize="sm">
Recevez une notification quand un pod d'une démo tombe en erreur (ou se
rétablit). Réglages propres à votre compte.
</Text>
</Box>
<FormControl>
<FormLabel fontSize="sm">Webhook Discord</FormLabel>
<Input
fontFamily="mono"
fontSize="sm"
placeholder="https://discord.com/api/webhooks/..."
value={discordWebhookUrl}
onChange={(e) => setDiscordWebhookUrl(e.target.value)}
isDisabled={savingAlerts}
/>
</FormControl>
<SimpleGrid columns={{ base: 1, sm: 2 }} spacing={4}>
<FormControl>
<FormLabel fontSize="sm">Bot Telegram (token)</FormLabel>
<Input
fontFamily="mono"
fontSize="sm"
placeholder="123456789:AAExemple..."
value={telegramBotToken}
onChange={(e) => setTelegramBotToken(e.target.value)}
isDisabled={savingAlerts}
/>
</FormControl>
<FormControl>
<FormLabel fontSize="sm">Telegram (chat ID)</FormLabel>
<Input
fontFamily="mono"
fontSize="sm"
placeholder="-100123456789"
value={telegramChatId}
onChange={(e) => setTelegramChatId(e.target.value)}
isDisabled={savingAlerts}
/>
<FormHelperText>
Envoyez un message au bot puis récupérez le chat_id via son API.
</FormHelperText>
</FormControl>
</SimpleGrid>
<Flex justify="flex-end">
<Button
size="sm"
colorScheme="primary"
isLoading={savingAlerts}
onClick={handleSaveAlertSettings}
>
Enregistrer les alertes
</Button>
</Flex>
</Stack>
</>
)}
<Divider borderColor="chakra-border-color" /> <Divider borderColor="chakra-border-color" />
<Flex justify="flex-end"> <Flex justify="flex-end">
+4 -2
View File
@@ -93,7 +93,7 @@ export function Subscription() {
return ( return (
<> <>
<Flex mb={6} align="center"> <Flex mb={6} align="center">
<Heading size="md">Ma démo</Heading> <Heading size="md">{isPremium ? 'Ma plateforme' : 'Ma démo'}</Heading>
<Spacer /> <Spacer />
</Flex> </Flex>
@@ -101,7 +101,9 @@ export function Subscription() {
{demosLoading ? ( {demosLoading ? (
<Spinner size="sm" /> <Spinner size="sm" />
) : demos.length === 0 ? ( ) : demos.length === 0 ? (
<Text color="gray.500">Aucune démo pour le moment.</Text> <Text color="gray.500">
{isPremium ? 'Aucune plateforme pour le moment.' : 'Aucune démo pour le moment.'}
</Text>
) : ( ) : (
<VStack align="stretch" spacing={3}> <VStack align="stretch" spacing={3}>
{demos.map((d) => ( {demos.map((d) => (
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/setupTests.ts","./src/theme.ts","./src/vite-env.d.ts","./src/components/BackofficeLayout.tsx","./src/components/ColorModeToggle.tsx","./src/components/ConfirmDialog.test.tsx","./src/components/ConfirmDialog.tsx","./src/components/CreateDemoModal.tsx","./src/components/Footer.tsx","./src/components/Header.tsx","./src/components/PodStatusPanel.tsx","./src/components/PublicLayout.tsx","./src/lib/api.ts","./src/lib/auth.tsx","./src/lib/format.test.ts","./src/lib/format.ts","./src/pages/Landing.tsx","./src/pages/Login.tsx","./src/pages/Pricing.tsx","./src/pages/Register.tsx","./src/pages/backoffice/Codes.tsx","./src/pages/backoffice/Demos.tsx","./src/pages/backoffice/PremiumDemos.tsx","./src/pages/backoffice/Profile.tsx","./src/pages/backoffice/Subscription.tsx"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/setupTests.ts","./src/theme.ts","./src/vite-env.d.ts","./src/components/BackofficeLayout.tsx","./src/components/ColorModeToggle.tsx","./src/components/ConfirmDialog.test.tsx","./src/components/ConfirmDialog.tsx","./src/components/CreateDemoModal.tsx","./src/components/Footer.tsx","./src/components/Header.tsx","./src/components/PasswordInput.tsx","./src/components/PodStatusPanel.tsx","./src/components/PublicLayout.tsx","./src/lib/api.ts","./src/lib/auth.tsx","./src/lib/format.test.ts","./src/lib/format.ts","./src/pages/AdminLogin.tsx","./src/pages/Landing.tsx","./src/pages/Login.tsx","./src/pages/Pricing.tsx","./src/pages/Register.tsx","./src/pages/backoffice/Codes.tsx","./src/pages/backoffice/Demos.tsx","./src/pages/backoffice/PremiumDemos.tsx","./src/pages/backoffice/Profile.tsx","./src/pages/backoffice/Subscription.tsx"],"version":"5.9.3"}