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

This commit is contained in:
Xor290
2026-08-02 21:31:29 +02:00
parent ca14327ea2
commit 2592ea1c6f
15 changed files with 338 additions and 1629 deletions
+19
View File
@@ -4,6 +4,7 @@ package demos
import (
"errors"
"regexp"
"strings"
"time"
)
@@ -33,8 +34,26 @@ const (
var (
ErrInvalidStorageDriver = errors.New("storage_driver doit être 'local' ou 's3'")
ErrS3ConfigIncomplete = errors.New("s3_bucket et s3_endpoint sont requis pour le stockage S3")
ErrInvalidDomain = errors.New("nom de domaine invalide")
)
// domainPattern : nom d'hôte RFC 1123 (labels alphanumériques + tirets,
// séparés par des points, au moins un point — pas de protocole ni de chemin).
var domainPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$`)
// ValidateDomain vérifie le format d'un domaine personnalisé. Une chaîne
// vide est valide : elle signifie "revenir au domaine par défaut" (voir
// Service.SetDomain).
func ValidateDomain(domain string) error {
if domain == "" {
return nil
}
if len(domain) > 253 || !domainPattern.MatchString(domain) {
return ErrInvalidDomain
}
return nil
}
// ProvisionConfig : réglages saisis par l'admin à la création (popup de
// déploiement), propagés jusqu'au chart backend. Jamais persisté en base —
// notamment les secrets (token Telegram, clés NowPayments), qui ne
@@ -179,6 +179,35 @@ func (h *Handler) Delete(c *gin.Context) {
c.JSON(http.StatusOK, d)
}
type setDomainRequest struct {
// Domain : nom d'hôte sans protocole ni chemin (ex. "boutique.client.com").
// Vide = réinitialise au domaine par défaut.
Domain string `json:"domain" binding:"omitempty,max=253"`
}
// SetDomain : POST /demos/:id/domain — modifie (ou réinitialise si vide) le
// domaine public d'une démo.
func (h *Handler) SetDomain(c *gin.Context) {
var req setDomainRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "requête invalide"})
return
}
d, err := h.svc.SetDomain(c.Param("id"), req.Domain)
if err != nil {
switch {
case errors.Is(err, ErrNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "démo introuvable"})
case errors.Is(err, ErrInvalidDomain):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "erreur serveur"})
}
return
}
c.JSON(http.StatusOK, d)
}
// Extend : POST /demos/:id/extend — prolonge de 30 jours.
func (h *Handler) Extend(c *gin.Context) {
d, err := h.svc.Extend(c.Param("id"))
@@ -21,6 +21,7 @@ import (
k8sCoreV1 "k8s.io/api/core/v1"
k8sErrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
@@ -314,6 +315,80 @@ func (h *HelmProvisioner) MigrateToPremiumNamespace(d Demo, newNamespace, newURL
return nil
}
// UpdateDomain change le domaine public d'une démo sans changer de
// namespace ni migrer de données : réinstalle l'IngressRoute avec le
// nouveau host (Traefik route sur le nom d'hôte), et si un bot Telegram
// et/ou le load-balancer lbtelegram sont configurés, met à jour leurs
// valeurs dépendantes du domaine (URL de webhook, GATEWAY_URL) puis force
// un redémarrage des pods concernés — un Secret modifié seul ne déclenche
// pas de rollout automatique côté Kubernetes.
//
// newURL : URL complète ("https://hôte[:port]") telle que calculée par
// Service.SetDomain. Best-effort sur backend/lbtelegram : une démo encore
// en cours de provisioning (releases pas encore installées) n'a que son
// IngressRoute mis à jour, sans erreur bloquante.
func (h *HelmProvisioner) UpdateDomain(d Demo, newURL string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
host := strings.TrimPrefix(strings.TrimPrefix(newURL, "https://"), "http://")
if i := strings.IndexByte(host, '/'); i >= 0 {
host = host[:i]
}
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i] // Host() de Traefik ne matche pas le port
}
ingressValues := map[string]interface{}{
"host": host,
"traefikNamespace": sharedTraefikNamespace,
}
if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-ingressroute", "ingressroute", ingressValues); err != nil {
return fmt.Errorf("mise à jour ingressroute: %w", err)
}
if backendValues, err := h.getReleaseValues(d.Namespace, d.Namespace+"-backend"); err == nil {
secrets := asStringMap(backendValues["secrets"])
if secrets["TELEGRAM_BOT_TOKEN"] != "" {
secrets["TELEGRAM_WEBHOOK_URL"] = newURL + "/webhook/telegram"
backendValues["secrets"] = secrets
if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-backend", "backend", backendValues); err != nil {
log.Printf("Warning: mise à jour webhook Telegram (backend) échouée pour %s: %v", d.Namespace, err)
} else if err := h.restartDeployment(ctx, d.Namespace, d.Namespace+"-backend-gestion-backend"); err != nil {
log.Printf("Warning: redémarrage backend échoué pour %s: %v", d.Namespace, err)
}
}
}
if lbValues, ok := h.getReleaseValuesOptional(d.Namespace, d.Namespace+"-lbtelegram"); ok {
env := asStringMap(lbValues["env"])
env["GATEWAY_URL"] = "https://" + host
lbValues["env"] = env
lbValues["host"] = host
if err := h.upgradeInstallChart(d.Namespace, d.Namespace+"-lbtelegram", "lbtelegram", lbValues); err != nil {
log.Printf("Warning: mise à jour host lbtelegram échouée pour %s: %v", d.Namespace, err)
} else if err := h.restartDeployment(ctx, d.Namespace, d.Namespace+"-lbtelegram-lbtelegram"); err != nil {
log.Printf("Warning: redémarrage lbtelegram échoué pour %s: %v", d.Namespace, err)
}
}
log.Printf("Domaine de %s mis à jour: %s", d.Namespace, host)
return nil
}
// restartDeployment force un nouveau rollout (annotation datée sur le pod
// template) pour qu'un Deployment reprenne en compte un Secret modifié sans
// changer d'image ni de spec structurel — Kubernetes ne redémarre pas les
// pods automatiquement dans ce cas.
func (h *HelmProvisioner) restartDeployment(ctx context.Context, namespace, name string) error {
patch := []byte(fmt.Sprintf(
`{"spec":{"template":{"metadata":{"annotations":{"omnex.app/restartedAt":%q}}}}}`,
time.Now().UTC().Format(time.RFC3339),
))
_, err := h.k8sClient.AppsV1().Deployments(namespace).Patch(ctx, name, types.StrategicMergePatchType, patch, metav1.PatchOptions{})
return err
}
// 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 {
+11 -8
View File
@@ -3,14 +3,17 @@ package demos
import "time"
type Demo struct {
ID string `gorm:"type:uuid;primaryKey" json:"id"`
Username string `gorm:"type:varchar(64);index" json:"username,omitempty"`
Status Status `gorm:"size:20;not null;index" json:"status"`
Namespace string `gorm:"size:63;uniqueIndex" json:"namespace"`
URL string `gorm:"size:255" json:"url"`
TypeAbo string `gorm:"type:varchar(35)" json:"type_abonnement"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
ID string `gorm:"type:uuid;primaryKey" json:"id"`
Username string `gorm:"type:varchar(64);index" json:"username,omitempty"`
Status Status `gorm:"size:20;not null;index" json:"status"`
Namespace string `gorm:"size:63;uniqueIndex" json:"namespace"`
URL string `gorm:"size:255" json:"url"`
// CustomDomain : domaine choisi par l'admin ("" = domaine par défaut
// "<namespace>.<baseDomain>", voir Service.SetDomain).
CustomDomain string `gorm:"size:255" json:"custom_domain,omitempty"`
TypeAbo string `gorm:"type:varchar(35)" json:"type_abonnement"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
func (Demo) TableName() string { return "demos" }
@@ -15,6 +15,10 @@ type Provisioner interface {
// 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
// UpdateDomain change le domaine public d'une démo (IngressRoute, et
// re-branchement des webhooks Telegram si configurés) sans changer de
// namespace ni migrer de données.
UpdateDomain(d Demo, newURL string) error
GetResourceState(ctx context.Context, namespace string) (ResourceState, error)
}
@@ -25,6 +29,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) UpdateDomain(Demo, string) error { return nil }
func (NoopProvisioner) GetResourceState(ctx context.Context, namespace string) (ResourceState, error) {
return ResourceState{}, nil
}
@@ -3,6 +3,7 @@ package demos
import (
"errors"
"log"
"strings"
"time"
"github.com/google/uuid"
@@ -192,6 +193,39 @@ func (s *Service) urlFor(namespace string) string {
return "https://" + namespace + "." + s.cfg.BaseDomain + portSuffix
}
// SetDomain modifie le domaine public d'une démo (IngressRoute, et
// re-branchement des webhooks Telegram si un bot est configuré — voir
// HelmProvisioner.UpdateDomain). domain vide réinitialise au domaine par
// défaut ("<namespace>.<baseDomain>"). Contrairement à
// MigrateToPremiumNamespace, c'est une opération rapide (aucune donnée à
// migrer, aucun namespace à recréer) : appliquée de façon synchrone.
func (s *Service) SetDomain(id, domain string) (Demo, error) {
domain = strings.ToLower(strings.TrimSpace(domain))
if err := ValidateDomain(domain); err != nil {
return Demo{}, err
}
d, ok := s.store.Get(id)
if !ok {
return Demo{}, ErrNotFound
}
newURL := domain
if newURL == "" {
newURL = s.urlFor(d.Namespace)
} else {
newURL = "https://" + domain
}
if err := s.prov.UpdateDomain(d, newURL); err != nil {
return Demo{}, err
}
d.CustomDomain = domain
d.URL = newURL
return s.store.Update(d)
}
// Delete déclenche le teardown et libère le pool.
func (s *Service) Delete(id string) (Demo, error) {
d, ok := s.store.Get(id)
@@ -76,6 +76,7 @@ func New(d Deps) *gin.Engine {
admin.GET("/demos/:id", d.DemosH.Get)
admin.DELETE("/demos/:id", d.DemosH.Delete)
admin.POST("/demos/:id/extend", d.DemosH.Extend)
admin.POST("/demos/:id/domain", d.DemosH.SetDomain)
admin.POST("/demos/details", d.DemosH.ListDetails)
}
}
-73
View File
@@ -1,73 +0,0 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-omnex}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-omnex}
POSTGRES_DB: ${POSTGRES_DB:-omnex}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-omnex} -d ${POSTGRES_DB:-omnex}"]
interval: 5s
timeout: 3s
retries: 10
# Pas de port exposé : accès interne uniquement (défense en profondeur).
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:-omnexredis}", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-omnexredis}", "ping"]
interval: 5s
timeout: 3s
retries: 10
web:
image: xor1234/omnex-web:latest
restart: unless-stopped
depends_on:
api:
condition: service_healthy
ports:
- "3000:80"
api:
image: xor1234/omnex-api:latest
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
OMNEX_ENV: ${OMNEX_ENV:-dev}
OMNEX_ADDR: ":8080"
OMNEX_JWT_SECRET: ${OMNEX_JWT_SECRET}
OMNEX_ALLOWED_ORIGINS: ${OMNEX_ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:5173}
OMNEX_DATABASE_URL: "host=postgres user=${POSTGRES_USER:-omnex} password=${POSTGRES_PASSWORD:-omnex} dbname=${POSTGRES_DB:-omnex} port=5432 sslmode=disable"
OMNEX_REDIS_URL: "redis://:${REDIS_PASSWORD:-omnexredis}@redis:6379/0"
OMNEX_SEED_USERNAME: ${OMNEX_SEED_USERNAME:-admin}
OMNEX_SEED_PASSWORD: ${OMNEX_SEED_PASSWORD}
OMNEX_DEMO_DOMAIN: ${OMNEX_DEMO_DOMAIN:-demo.omnex.app}
FRONTEND_IMAGE_APP: ${FRONTEND_IMAGE_APP:-xor1234/frontend-mln:latest}
BACKEND_IMAGE_APP: ${BACKEND_IMAGE_APP:-xor1234/backend-mln:latest}
KUBECONFIG: /kubeconfig/config
volumes:
- ./deploy/chart-gestion:/charts:ro
- /home/xor_fakers/.kube/config:/kubeconfig/config:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"]
interval: 5s
timeout: 3s
retries: 10
ports:
- "8080:8080"
volumes:
pgdata:
redisdata:
-1510
View File
File diff suppressed because one or more lines are too long
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<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-BZ4jKugg.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react'
import {
Button,
FormControl,
FormHelperText,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
useToast,
} from '@chakra-ui/react'
import { api, ApiError, type Demo } from '../lib/api'
interface EditDomainModalProps {
demo: Demo | null
onClose: () => void
onSaved: () => void
}
// Modal d'édition du domaine public d'une démo — partagé entre le dashboard
// Demos et le dashboard Premium.
export function EditDomainModal({ demo, onClose, onSaved }: EditDomainModalProps) {
const toast = useToast()
const [domain, setDomain] = useState('')
const [saving, setSaving] = useState(false)
useEffect(() => {
setDomain(demo?.custom_domain ?? '')
}, [demo])
const handleClose = () => {
if (saving) return
onClose()
}
const handleSave = async () => {
if (!demo) return
setSaving(true)
try {
await api.setDemoDomain(demo.id, domain.trim())
toast({ status: 'success', title: 'Domaine mis à jour' })
onSaved()
onClose()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Mise à jour impossible', description: msg })
} finally {
setSaving(false)
}
}
return (
<Modal isOpen={!!demo} onClose={handleClose} closeOnOverlayClick={!saving}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Domaine de la plateforme</ModalHeader>
<ModalCloseButton isDisabled={saving} />
<ModalBody>
<FormControl>
<FormLabel fontSize="sm">Domaine personnalisé</FormLabel>
<Input
placeholder="boutique.mon-domaine.com"
value={domain}
onChange={(e) => setDomain(e.target.value)}
isDisabled={saving}
fontFamily="mono"
/>
<FormHelperText>
Laissez vide pour revenir au domaine par défaut ({demo?.namespace}.&lt;domaine
omnex&gt;). Le DNS du domaine choisi doit pointer vers le cluster, et un certificat
TLS valide doit le couvrir le certificat wildcard partagé ne couvre que le domaine
par défaut.
</FormHelperText>
</FormControl>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={handleClose} isDisabled={saving}>
Annuler
</Button>
<Button colorScheme="primary" onClick={handleSave} isLoading={saving}>
Enregistrer
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
+3
View File
@@ -58,6 +58,7 @@ export interface Demo {
status: DemoStatus
namespace: string
url: string
custom_domain?: string
type_abonnement?: string
created_at: string
expires_at: string
@@ -186,6 +187,8 @@ export const api = {
}),
extendDemo: (id: string) => request<Demo>('POST', `/demos/${id}/extend`),
deleteDemo: (id: string) => request<Demo>('DELETE', `/demos/${id}`),
setDemoDomain: (id: string, domain: string) =>
request<Demo>('POST', `/demos/${id}/domain`, { domain }),
listCodes: () => request<{ items: CodeBuySub[] }>('GET', '/codes'),
createCode: (username: string) =>
+34 -12
View File
@@ -8,6 +8,7 @@ import {
Heading,
HStack,
Icon,
IconButton,
Link,
Spacer,
Spinner,
@@ -21,11 +22,13 @@ import {
Tr,
useToast,
} from '@chakra-ui/react'
import { EditIcon } from '@chakra-ui/icons'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, type Demo, type DemoDetails } from '../../lib/api'
import { statusColor, statusLabel, timeRemaining } from '../../lib/format'
import { ConfirmDialog } from '../../components/ConfirmDialog'
import { CreateDemoModal } from '../../components/CreateDemoModal'
import { EditDomainModal } from '../../components/EditDomainModal'
import { ChevronIcon, PodStatusPanel } from '../../components/PodStatusPanel'
// Un provisioning en cours => on rafraîchit régulièrement.
@@ -41,6 +44,7 @@ export function Demos() {
const [busy, setBusy] = useState<string | null>(null)
const [toDelete, setToDelete] = useState<Demo | null>(null)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [editingDomain, setEditingDomain] = useState<Demo | null>(null)
// --- Ligne dépliée (état live des pods) ---
const [expandedId, setExpandedId] = useState<string | null>(null)
@@ -209,18 +213,30 @@ export function Demos() {
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
</Td>
<Td>
{d.status === 'ready' ? (
<Link
href={d.url}
color="primary.500"
isExternal
onClick={(e) => e.stopPropagation()}
>
{d.url}
</Link>
) : (
<Text color="gray.400"></Text>
)}
<HStack spacing={1}>
{d.status === 'ready' ? (
<Link
href={d.url}
color="primary.500"
isExternal
onClick={(e) => e.stopPropagation()}
>
{d.url}
</Link>
) : (
<Text color="gray.400"></Text>
)}
<IconButton
aria-label="Modifier le domaine"
icon={<EditIcon />}
size="xs"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
setEditingDomain(d)
}}
/>
</HStack>
</Td>
<Td>{isAlive(d.status) ? timeRemaining(d.expires_at) : '—'}</Td>
<Td textAlign="right">
@@ -293,6 +309,12 @@ export function Demos() {
et toutes ses données seront supprimées définitivement. Les ressources du pool seront
libérées. Cette action est irréversible.
</ConfirmDialog>
<EditDomainModal
demo={editingDomain}
onClose={() => setEditingDomain(null)}
onSaved={() => void load()}
/>
</>
)
}
+34 -12
View File
@@ -8,6 +8,7 @@ import {
Heading,
HStack,
Icon,
IconButton,
Link,
Spacer,
Spinner,
@@ -21,11 +22,13 @@ import {
Tr,
useToast,
} from '@chakra-ui/react'
import { EditIcon } from '@chakra-ui/icons'
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'
import { EditDomainModal } from '../../components/EditDomainModal'
// Un provisioning en cours => on rafraîchit régulièrement.
const POLL_MS = 5000
@@ -38,6 +41,7 @@ export function PremiumDemos() {
const [demos, setDemos] = useState<Demo[]>([])
const [loading, setLoading] = useState(true)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [editingDomain, setEditingDomain] = useState<Demo | null>(null)
// --- Ligne dépliée (état live des pods) ---
const [expandedId, setExpandedId] = useState<string | null>(null)
@@ -165,18 +169,30 @@ export function PremiumDemos() {
<Badge colorScheme={statusColor(d.status)}>{statusLabel(d.status)}</Badge>
</Td>
<Td>
{d.status === 'ready' ? (
<Link
href={d.url}
color="primary.500"
isExternal
onClick={(e) => e.stopPropagation()}
>
{d.url}
</Link>
) : (
<Text color="gray.400"></Text>
)}
<HStack spacing={1}>
{d.status === 'ready' ? (
<Link
href={d.url}
color="primary.500"
isExternal
onClick={(e) => e.stopPropagation()}
>
{d.url}
</Link>
) : (
<Text color="gray.400"></Text>
)}
<IconButton
aria-label="Modifier le domaine"
icon={<EditIcon />}
size="xs"
variant="ghost"
onClick={(e) => {
e.stopPropagation()
setEditingDomain(d)
}}
/>
</HStack>
</Td>
</Tr>
<Tr>
@@ -203,6 +219,12 @@ export function PremiumDemos() {
</Table>
</TableContainer>
)}
<EditDomainModal
demo={editingDomain}
onClose={() => setEditingDomain(null)}
onSaved={() => void load()}
/>
</>
)
}
+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/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"}
{"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/EditDomainModal.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"}