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

This commit is contained in:
Xor290
2026-08-02 18:34:59 +02:00
parent 6992e505ae
commit c5ee5636ef
34 changed files with 2660 additions and 498 deletions
+11 -2
View File
@@ -1,8 +1,9 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { Center, Spinner } from '@chakra-ui/react'
import { 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é */}
+18 -1
View File
@@ -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>
+7 -12
View File
@@ -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>
+2 -2
View File
@@ -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}
+26
View File
@@ -0,0 +1,26 @@
import { forwardRef, useState } from 'react'
import { IconButton, Input, InputGroup, InputRightElement, type InputProps } from '@chakra-ui/react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'
// Input mot de passe avec bascule affichage clair/masqué (icône Font Awesome).
export const PasswordInput = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
const [visible, setVisible] = useState(false)
return (
<InputGroup>
<Input ref={ref} type={visible ? 'text' : 'password'} {...props} />
<InputRightElement>
<IconButton
aria-label={visible ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
icon={<FontAwesomeIcon icon={visible ? faEyeSlash : faEye} />}
size="sm"
variant="ghost"
tabIndex={-1}
onClick={() => setVisible((v) => !v)}
/>
</InputRightElement>
</InputGroup>
)
})
PasswordInput.displayName = 'PasswordInput'
+11 -1
View File
@@ -133,13 +133,20 @@ export interface TelegramInfo {
telegram: string
}
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),
}
+3 -3
View File
@@ -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)
+92
View File
@@ -0,0 +1,92 @@
import { useState } from 'react'
import {
Box,
Button,
Card,
CardBody,
Container,
FormControl,
FormLabel,
Heading,
Input,
Stack,
Text,
useToast,
} from '@chakra-ui/react'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { useAuth } from '../lib/auth'
import { ApiError } from '../lib/api'
import { ColorModeToggle } from '../components/ColorModeToggle'
import { PasswordInput } from '../components/PasswordInput'
// Page de connexion admin, séparée de la page client (/login) : identifiants
// admin et client ne sont pas interchangeables entre les deux pages (voir
// contrôle du rôle côté backend, auth.Handler.Login). Pas de page
// d'inscription pour l'admin — les comptes admin sont provisionnés
// directement (seed), jamais auto-créés.
export function AdminLogin() {
const { login } = useAuth()
const navigate = useNavigate()
const toast = useToast()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
await login(username.trim(), password, 'admin')
navigate('/app', { replace: true })
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Connexion impossible'
toast({ status: 'error', title: 'Échec de connexion', description: msg })
} finally {
setLoading(false)
}
}
return (
<Container maxW="sm" py={20} position="relative">
<Box position="absolute" top={4} right={4}>
<ColorModeToggle />
</Box>
<Stack spacing={6}>
<Box textAlign="center">
<Heading size="lg">Espace admin</Heading>
<Text color="gray.500">Connectez-vous pour administrer la plateforme.</Text>
</Box>
<Card>
<CardBody>
<form onSubmit={onSubmit}>
<Stack spacing={4}>
<FormControl isRequired>
<FormLabel>Nom d'utilisateur</FormLabel>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
</FormControl>
<FormControl isRequired>
<FormLabel>Mot de passe</FormLabel>
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</FormControl>
<Button type="submit" colorScheme="primary" isLoading={loading}>
Se connecter
</Button>
</Stack>
</form>
</CardBody>
</Card>
<Button as={RouterLink} to="/" variant="link" size="sm">
Retour au site
</Button>
</Stack>
</Container>
)
}
+5 -5
View File
@@ -18,6 +18,7 @@ import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { useAuth } from '../lib/auth'
import { 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"
+3 -4
View File
@@ -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"
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+21 -4
View File
@@ -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 ? (
+128 -4
View File
@@ -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">
+4 -2
View File
@@ -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) => (