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
+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) => (