chore: update
This commit is contained in:
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user