chore: update
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"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/config"
|
||||
"github.com/omnex/control-plane/api/internal/db"
|
||||
@@ -67,6 +68,10 @@ func main() {
|
||||
var demoPool demos.Pool
|
||||
var codeStore sub.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 != "" {
|
||||
gdb, err := db.Open(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
@@ -85,6 +90,7 @@ func main() {
|
||||
demoStore = demos.NewGormStore(gdb)
|
||||
demoPool = demos.NewGormPool(gdb)
|
||||
profileStore = profile.NewGormStore(gdb)
|
||||
alertRecipients = alerts.GormRecipients(gdb)
|
||||
log.Printf("persistance: PostgreSQL (GORM)")
|
||||
} else {
|
||||
userStore = seedMemUsers()
|
||||
@@ -141,6 +147,30 @@ func main() {
|
||||
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{
|
||||
Cfg: cfg,
|
||||
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 ""
|
||||
}
|
||||
@@ -30,10 +30,16 @@ func NewHandler(users UserStore, sessions session.Manager, iss *Issuer, secure b
|
||||
type loginRequest struct {
|
||||
Username 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.
|
||||
// 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) {
|
||||
var req loginRequest
|
||||
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)
|
||||
if err != nil || !ok {
|
||||
if err != nil || !ok || user.Role != req.Role {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "identifiants invalides"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ type User struct {
|
||||
Role Role `gorm:"size:20;not null" json:"role"`
|
||||
TypeAbo string `gorm:"type:varchar(35);default:demo" json:"type_abonnement"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package demos
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -145,3 +146,20 @@ const (
|
||||
ResourceFree ResourceStatus = "free"
|
||||
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"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -206,6 +207,227 @@ func (h *HelmProvisioner) Teardown(d Demo) error {
|
||||
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"
|
||||
// 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
|
||||
@@ -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.
|
||||
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...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
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) {
|
||||
|
||||
@@ -10,6 +10,11 @@ type Provisioner interface {
|
||||
Provision(d Demo, resources []ExternalResource, cfg ProvisionConfig) error
|
||||
// Teardown demande la destruction d'une démo.
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -19,6 +24,7 @@ type NoopProvisioner struct{}
|
||||
|
||||
func (NoopProvisioner) Provision(Demo, []ExternalResource, ProvisionConfig) 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) {
|
||||
return ResourceState{}, nil
|
||||
}
|
||||
|
||||
@@ -75,15 +75,7 @@ func (s *Service) Create(username string, cfg ProvisionConfig) (Demo, error) {
|
||||
CreatedAt: s.now().UTC(),
|
||||
ExpiresAt: s.now().UTC().Add(s.cfg.TTL),
|
||||
}
|
||||
// Port omis dans l'URL 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.
|
||||
portSuffix := ""
|
||||
if s.cfg.HTTPSPort != "" && s.cfg.HTTPSPort != "443" {
|
||||
portSuffix = ":" + s.cfg.HTTPSPort
|
||||
}
|
||||
demo.URL = "https://" + demo.Namespace + "." + s.cfg.BaseDomain + portSuffix
|
||||
demo.URL = s.urlFor(demo.Namespace)
|
||||
|
||||
// Réserve les ressources externes (slots Telegram/NowPayments pré-
|
||||
// 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
|
||||
// (n'expire plus). Le stockage (PVC postgres/redis) est déjà en place depuis
|
||||
// la création de la démo (voir HelmProvisioner.Provision) : rien à basculer
|
||||
// côté infra, seul le statut change. No-op 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).
|
||||
// (n'expire plus) et déclenche la migration de son namespace vers
|
||||
// "premium-<client>" (Kubernetes ne permet pas de renommer un namespace,
|
||||
// voir HelmProvisioner.MigrateToPremiumNamespace) — no-op si déjà migrée
|
||||
// (ex. renouvellement d'un client déjà premium). Le stockage (PVC
|
||||
// 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 {
|
||||
existing, err := s.store.ListByUsername(auth.NormalizeUsername(username))
|
||||
if err != nil {
|
||||
@@ -158,13 +155,43 @@ func (s *Service) TransferToPaid(username string) error {
|
||||
|
||||
active.TypeAbo = "premium"
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *Service) Delete(id string) (Demo, error) {
|
||||
d, ok := s.store.Get(id)
|
||||
|
||||
@@ -19,6 +19,8 @@ type Store interface {
|
||||
UpdatePassword(id, passwordHahs string) (auth.User, error)
|
||||
GetTelegram(id 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) {
|
||||
@@ -71,3 +73,36 @@ func (s *GormStore) SetTelegram(id, telegram string) (auth.User, error) {
|
||||
}
|
||||
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"`
|
||||
}
|
||||
|
||||
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) {
|
||||
var u newUsername
|
||||
|
||||
@@ -110,3 +136,40 @@ func (h *Handler) SetTelegramById(c *gin.Context) {
|
||||
}
|
||||
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.POST("/codes", d.SubH.CreateCodeForBuy)
|
||||
admin.GET("/profile/alerts", d.ProfileH.GetAlerts)
|
||||
admin.POST("/profile/alerts", d.ProfileH.SetAlerts)
|
||||
if d.DemosH != nil {
|
||||
admin.POST("/demos", d.DemosH.Create)
|
||||
admin.GET("/demos", d.DemosH.List)
|
||||
|
||||
Vendored
+1510
File diff suppressed because one or more lines are too long
Vendored
-423
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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." />
|
||||
<script type="module" crossorigin src="/assets/index-DRDLx_J5.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BZ4jKugg.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+70
-10
@@ -12,6 +12,9 @@
|
||||
"@chakra-ui/react": "^2.10.4",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@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",
|
||||
"framer-motion": "^11.11.0",
|
||||
"react": "^18.3.1",
|
||||
@@ -89,6 +92,7 @@
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^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",
|
||||
"integrity": "sha512-uahxAfb83/O9fcgBm3ew/nVeQiNgE0uQf8NdQQp3I6u/PCuR2OjSEneV4Ba86W8z50Ppwcxvf9XDk5btYceggQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/hooks": "2.4.6",
|
||||
"@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",
|
||||
"integrity": "sha512-sy39LJWnOvGcOHYBXQxT3bC5zj+R7GVUgxzkkoqx+auV5KVKOOE5aZD+POsciWEt86bqdEa+LpFm+E5pcdbkyg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/utils": "2.2.6",
|
||||
"csstype": "^3.1.2"
|
||||
@@ -541,6 +547,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -564,6 +571,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -626,6 +634,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -669,6 +678,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
||||
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -1105,6 +1115,53 @@
|
||||
"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": {
|
||||
"version": "3.10.0",
|
||||
"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",
|
||||
"integrity": "sha512-rSit+RrsV1xkrli4tne1TjkEjGbkyMefNhP34iNfjbrWMuyu1F/csRx8F6tarYq7NRFmYbF6AnTY6ijgDQ2WBQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@chakra-ui/utils": "^2.2.3",
|
||||
"@saas-ui/core": "2.8.1",
|
||||
@@ -1948,8 +2006,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -2037,6 +2094,7 @@
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -2048,6 +2106,7 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -2288,7 +2347,6 @@
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -2299,7 +2357,6 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -2394,6 +2451,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.44",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
@@ -2661,8 +2719,7 @@
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
@@ -2884,6 +2941,7 @@
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
|
||||
"integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"motion-dom": "^11.18.1",
|
||||
"motion-utils": "^11.18.1",
|
||||
@@ -3186,6 +3244,7 @@
|
||||
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.1.0",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -3299,7 +3358,6 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -3539,7 +3597,6 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -3554,8 +3611,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
@@ -3583,6 +3639,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -3628,6 +3685,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -3670,6 +3728,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.82.0.tgz",
|
||||
"integrity": "sha512-Zw/uFZ2dO+02GHlBn7JFGn8kZJ7LdM33B/0BXOovzFay+CMhf94JMw5BVu+F1tVkUKjNvBuaE3fz5BJhga10Tg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -4233,6 +4292,7 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"@chakra-ui/react": "^2.10.4",
|
||||
"@emotion/react": "^11.13.3",
|
||||
"@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",
|
||||
"framer-motion": "^11.11.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
+11
-2
@@ -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 { Landing } from './pages/Landing'
|
||||
import { Pricing } from './pages/Pricing'
|
||||
import { Login } from './pages/Login'
|
||||
import { AdminLogin } from './pages/AdminLogin'
|
||||
import { Register } from './pages/Register'
|
||||
import { Demos } from './pages/backoffice/Demos'
|
||||
import { PremiumDemos } from './pages/backoffice/PremiumDemos'
|
||||
@@ -14,8 +15,13 @@ import { BackofficeLayout } from './components/BackofficeLayout'
|
||||
import { useAuth } from './lib/auth'
|
||||
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 }) {
|
||||
const { isAuthenticated, initializing } = useAuth()
|
||||
const location = useLocation()
|
||||
if (initializing) {
|
||||
return (
|
||||
<Center h="100vh">
|
||||
@@ -23,7 +29,9 @@ function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
</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.
|
||||
@@ -52,6 +60,7 @@ export function App() {
|
||||
<Route path="/tarifs" element={<Pricing />} />
|
||||
</Route>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/admin/login" element={<AdminLogin />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
|
||||
{/* Back-office protégé */}
|
||||
|
||||
@@ -28,14 +28,17 @@ const HamburgerIcon = () => (
|
||||
</Box>
|
||||
)
|
||||
|
||||
const SUPPORT_TELEGRAM_URL = 'https://t.me/OMNEX_CORP'
|
||||
|
||||
export function BackofficeLayout() {
|
||||
const { logout, isAdmin, isClient } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
|
||||
const onLogout = async () => {
|
||||
const loginPath = isAdmin ? '/admin/login' : '/login'
|
||||
await logout()
|
||||
navigate('/login', { replace: true })
|
||||
navigate(loginPath, { replace: true })
|
||||
}
|
||||
|
||||
const roleLabel = isAdmin ? 'Admin' : isClient ? 'Client' : 'Utilisateur'
|
||||
@@ -57,6 +60,9 @@ export function BackofficeLayout() {
|
||||
<Spacer />
|
||||
<HStack spacing={2} display={{ base: 'none', md: 'flex' }}>
|
||||
<Badge colorScheme={roleColor}>{roleLabel}</Badge>
|
||||
<Button as="a" href={SUPPORT_TELEGRAM_URL} target="_blank" rel="noopener noreferrer" size="sm" variant="outline">
|
||||
Support
|
||||
</Button>
|
||||
<ColorModeToggle />
|
||||
<Button size="sm" variant="outline" onClick={onLogout}>
|
||||
Déconnexion
|
||||
@@ -115,6 +121,17 @@ export function BackofficeLayout() {
|
||||
<Divider my={6} />
|
||||
|
||||
<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}>
|
||||
Déconnexion
|
||||
</Button>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
useToast,
|
||||
} from '@chakra-ui/react'
|
||||
import { api, ApiError, type CreateDemoParams, type StorageDriver } from '../lib/api'
|
||||
import { PasswordInput } from './PasswordInput'
|
||||
|
||||
interface CreateDemoModalProps {
|
||||
isOpen: boolean
|
||||
@@ -179,11 +180,10 @@ export function CreateDemoModal({ isOpen, onClose, onCreated }: CreateDemoModalP
|
||||
</FormControl>
|
||||
<FormControl isRequired isDisabled={submitting}>
|
||||
<FormLabel fontSize="sm">Mot de passe</FormLabel>
|
||||
<Input
|
||||
<PasswordInput
|
||||
placeholder="8 caractères min."
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -212,11 +212,10 @@ export function CreateDemoModal({ isOpen, onClose, onCreated }: CreateDemoModalP
|
||||
</FormControl>
|
||||
<FormControl isDisabled={submitting}>
|
||||
<FormLabel fontSize="sm">Token bot Telegram</FormLabel>
|
||||
<Input
|
||||
<PasswordInput
|
||||
placeholder="123456:ABC-DEF..."
|
||||
value={telegramBotToken}
|
||||
onChange={(e) => setTelegramBotToken(e.target.value)}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -237,21 +236,19 @@ export function CreateDemoModal({ isOpen, onClose, onCreated }: CreateDemoModalP
|
||||
<Stack spacing={3} pl={3} borderLeftWidth="2px" borderColor="primary.500">
|
||||
<FormControl isDisabled={submitting}>
|
||||
<FormLabel fontSize="sm">Clé API NowPayments</FormLabel>
|
||||
<Input
|
||||
<PasswordInput
|
||||
placeholder="clé API du compte marchand"
|
||||
value={nowPaymentsApiKey}
|
||||
onChange={(e) => setNowPaymentsApiKey(e.target.value)}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl isDisabled={submitting}>
|
||||
<FormLabel fontSize="sm">Secret IPN NowPayments</FormLabel>
|
||||
<Input
|
||||
<PasswordInput
|
||||
placeholder="secret configuré côté NowPayments"
|
||||
value={nowPaymentsIpnSecret}
|
||||
onChange={(e) => setNowPaymentsIpnSecret(e.target.value)}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Text fontSize="xs" color="gray.500" mt={1}>
|
||||
@@ -287,11 +284,10 @@ export function CreateDemoModal({ isOpen, onClose, onCreated }: CreateDemoModalP
|
||||
</FormControl>
|
||||
<FormControl isDisabled={submitting}>
|
||||
<FormLabel fontSize="sm">Bot 1 — token</FormLabel>
|
||||
<Input
|
||||
<PasswordInput
|
||||
placeholder="123456:ABC-DEF..."
|
||||
value={lbBot1Token}
|
||||
onChange={(e) => setLbBot1Token(e.target.value)}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -307,11 +303,10 @@ export function CreateDemoModal({ isOpen, onClose, onCreated }: CreateDemoModalP
|
||||
</FormControl>
|
||||
<FormControl isDisabled={submitting}>
|
||||
<FormLabel fontSize="sm">Bot 2 — token</FormLabel>
|
||||
<Input
|
||||
<PasswordInput
|
||||
placeholder="123456:ABC-DEF..."
|
||||
value={lbBot2Token}
|
||||
onChange={(e) => setLbBot2Token(e.target.value)}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function Header() {
|
||||
<HStack spacing={2} display={{ base: 'none', md: 'flex' }}>
|
||||
<ColorModeToggle />
|
||||
<Button as={RouterLink} to="/login" variant="ghost" size="sm">
|
||||
Espace commercial
|
||||
Espace client
|
||||
</Button>
|
||||
<Button as={RouterLink} to="/register" colorScheme="primary" size="sm">
|
||||
Créer un compte
|
||||
@@ -109,7 +109,7 @@ export function Header() {
|
||||
justifyContent="flex-start"
|
||||
onClick={onClose}
|
||||
>
|
||||
Espace commercial
|
||||
Espace client
|
||||
</Button>
|
||||
<Button
|
||||
as={RouterLink}
|
||||
|
||||
@@ -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
@@ -133,13 +133,20 @@ export interface TelegramInfo {
|
||||
telegram: string
|
||||
}
|
||||
|
||||
export interface AlertSettings {
|
||||
discord_webhook_url: string
|
||||
telegram_bot_token: string
|
||||
telegram_chat_id: string
|
||||
}
|
||||
|
||||
// --- Endpoints ---
|
||||
|
||||
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', {
|
||||
username,
|
||||
password,
|
||||
role,
|
||||
}),
|
||||
register: (username: string, password: string) =>
|
||||
request<{ token: string; token_type: string; role: Role }>('POST', '/auth/register', {
|
||||
@@ -197,4 +204,7 @@ export const api = {
|
||||
getTelegram: () => request<TelegramInfo>('GET', '/profile/telegram'),
|
||||
setTelegram: (telegram: string) =>
|
||||
request<TelegramInfo>('POST', '/profile/telegram', { telegram }),
|
||||
getAlertSettings: () => request<AlertSettings>('GET', '/profile/alerts'),
|
||||
setAlertSettings: (settings: AlertSettings) =>
|
||||
request<AlertSettings>('POST', '/profile/alerts', settings),
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ interface AuthState {
|
||||
role: Role | null
|
||||
typeAbo: string | null
|
||||
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>
|
||||
logout: () => Promise<void>
|
||||
refreshAbo: () => Promise<void>
|
||||
@@ -62,8 +62,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const res = await api.login(username, password)
|
||||
const login = useCallback(async (username: string, password: string, role: Role) => {
|
||||
const res = await api.login(username, password, role)
|
||||
setToken(res.token)
|
||||
setTok(res.token)
|
||||
setRole(res.role)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ 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'
|
||||
|
||||
export function Login() {
|
||||
const { login } = useAuth()
|
||||
@@ -31,7 +32,7 @@ export function Login() {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(username.trim(), password)
|
||||
await login(username.trim(), password, 'client')
|
||||
navigate('/app', { replace: true })
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : 'Connexion impossible'
|
||||
@@ -48,8 +49,8 @@ export function Login() {
|
||||
</Box>
|
||||
<Stack spacing={6}>
|
||||
<Box textAlign="center">
|
||||
<Heading size="lg">Espace commercial</Heading>
|
||||
<Text color="gray.500">Connectez-vous pour gérer les démos.</Text>
|
||||
<Heading size="lg">Espace client</Heading>
|
||||
<Text color="gray.500">Connectez-vous pour gérer votre abonnement.</Text>
|
||||
</Box>
|
||||
<Card>
|
||||
<CardBody>
|
||||
@@ -65,8 +66,7 @@ export function Login() {
|
||||
</FormControl>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Mot de passe</FormLabel>
|
||||
<Input
|
||||
type="password"
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
|
||||
@@ -19,6 +19,7 @@ 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'
|
||||
|
||||
export function Register() {
|
||||
const { register } = useAuth()
|
||||
@@ -76,8 +77,7 @@ export function Register() {
|
||||
|
||||
<FormControl isRequired isInvalid={password.length > 0 && !passwordValid}>
|
||||
<FormLabel>Mot de passe</FormLabel>
|
||||
<Input
|
||||
type="password"
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
@@ -87,8 +87,7 @@ export function Register() {
|
||||
|
||||
<FormControl isRequired isInvalid={confirm.length > 0 && !passwordsMatch}>
|
||||
<FormLabel>Confirmer le mot de passe</FormLabel>
|
||||
<Input
|
||||
type="password"
|
||||
<PasswordInput
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
|
||||
@@ -43,7 +43,7 @@ export function Codes() {
|
||||
const res = await api.listCodes()
|
||||
setCodes(res.items ?? [])
|
||||
} 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' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
||||
@@ -55,7 +55,7 @@ export function Demos() {
|
||||
// les mélanger avec les démos d'essai ici.
|
||||
setDemos((res.items ?? []).filter((d) => d.type_abonnement !== 'premium'))
|
||||
} 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' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
|
||||
@@ -2,11 +2,14 @@ import { Fragment, useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
Flex,
|
||||
Heading,
|
||||
HStack,
|
||||
Icon,
|
||||
Link,
|
||||
Spacer,
|
||||
Spinner,
|
||||
Table,
|
||||
TableContainer,
|
||||
@@ -22,6 +25,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { api, ApiError, type Demo, type DemoDetails } from '../../lib/api'
|
||||
import { statusColor, statusLabel } from '../../lib/format'
|
||||
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
|
||||
import { CreateDemoModal } from '../../components/CreateDemoModal'
|
||||
|
||||
// Un provisioning en cours => on rafraîchit régulièrement.
|
||||
const POLL_MS = 5000
|
||||
@@ -33,6 +37,7 @@ export function PremiumDemos() {
|
||||
const navigate = useNavigate()
|
||||
const [demos, setDemos] = useState<Demo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
|
||||
// --- Ligne dépliée (état live des pods) ---
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
@@ -44,7 +49,7 @@ export function PremiumDemos() {
|
||||
const res = await api.listDemos()
|
||||
setDemos((res.items ?? []).filter((d) => d.type_abonnement === 'premium'))
|
||||
} 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' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -98,13 +103,25 @@ export function PremiumDemos() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading size="md" mb={2}>
|
||||
Démos Premium
|
||||
</Heading>
|
||||
<Flex mb={2} align="center" gap={4} wrap="wrap">
|
||||
<Heading size="md" mr={4}>
|
||||
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">
|
||||
Démos rattachées à un client passé en abonnement payant — stockage persistant, n'expirent plus.
|
||||
</Text>
|
||||
|
||||
<CreateDemoModal
|
||||
isOpen={createModalOpen}
|
||||
onClose={() => setCreateModalOpen(false)}
|
||||
onCreated={() => void load()}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : demos.length === 0 ? (
|
||||
|
||||
@@ -11,9 +11,13 @@ import {
|
||||
EditableInput,
|
||||
EditablePreview,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Heading,
|
||||
HStack,
|
||||
IconButton,
|
||||
Input,
|
||||
SimpleGrid,
|
||||
Spinner,
|
||||
Stack,
|
||||
@@ -25,6 +29,8 @@ import {
|
||||
useToast,
|
||||
} from '@chakra-ui/react'
|
||||
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 { api, ApiError } from '../../lib/api'
|
||||
import { useAuth } from '../../lib/auth'
|
||||
@@ -101,18 +107,30 @@ export function Profile() {
|
||||
const [updatingUsername, setUpdatingUsername] = useState(false)
|
||||
const [updatingPassword, setUpdatingPassword] = useState(false)
|
||||
const [passwordVersion, setPasswordVersion] = useState(0)
|
||||
const [passwordVisible, setPasswordVisible] = useState(false)
|
||||
const [telegram, setTelegramState] = useState('')
|
||||
const [updatingTelegram, setUpdatingTelegram] = 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(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const [meRes, tgRes] = await Promise.all([api.me(), api.getTelegram()])
|
||||
if (!cancelled) {
|
||||
setMe(meRes)
|
||||
setTelegramState(tgRes.telegram ?? '')
|
||||
if (cancelled) return
|
||||
setMe(meRes)
|
||||
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) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
@@ -127,6 +145,33 @@ export function Profile() {
|
||||
return () => { cancelled = true }
|
||||
}, [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 trimmed = newUsername.trim()
|
||||
if (!me || !trimmed || trimmed === me.username) return
|
||||
@@ -310,7 +355,19 @@ export function Profile() {
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<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 />
|
||||
</HStack>
|
||||
</Editable>
|
||||
@@ -359,6 +416,73 @@ export function Profile() {
|
||||
</Stat>
|
||||
</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" />
|
||||
|
||||
<Flex justify="flex-end">
|
||||
|
||||
@@ -93,7 +93,7 @@ export function Subscription() {
|
||||
return (
|
||||
<>
|
||||
<Flex mb={6} align="center">
|
||||
<Heading size="md">Ma démo</Heading>
|
||||
<Heading size="md">{isPremium ? 'Ma plateforme' : 'Ma démo'}</Heading>
|
||||
<Spacer />
|
||||
</Flex>
|
||||
|
||||
@@ -101,7 +101,9 @@ export function Subscription() {
|
||||
{demosLoading ? (
|
||||
<Spinner size="sm" />
|
||||
) : 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}>
|
||||
{demos.map((d) => (
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user