chore: update
ci-api / test (push) Successful in 24m6s
ci-web / test (push) Successful in 13m50s

This commit is contained in:
Xor290
2026-09-20 11:50:02 +02:00
parent cb48bb2b70
commit 76b1412968
23 changed files with 2141 additions and 89 deletions
+11 -1
View File
@@ -9,6 +9,7 @@ import { Register } from './pages/Register'
import { Demos } from './pages/backoffice/Demos'
import { PremiumDemos } from './pages/backoffice/PremiumDemos'
import { Codes } from './pages/backoffice/Codes'
import { Projects } from './pages/backoffice/Projects'
import { Subscription } from './pages/backoffice/Subscription'
import { AppDownloads } from './pages/backoffice/AppDownloads'
import { Profile } from './pages/backoffice/Profile'
@@ -21,7 +22,7 @@ import { MyDemoPlat } from './pages/backoffice/MyDemoPlat'
// 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']
const ADMIN_ONLY_PATHS = ['/app/demos', '/app/premium', '/app/projects', '/app/codes']
function RequireAuth({ children }: { children: JSX.Element }) {
const { isAuthenticated, initializing } = useAuth()
@@ -98,6 +99,15 @@ export function App() {
}
/>
<Route
path="projects"
element={
<RequireAdmin>
<Projects />
</RequireAdmin>
}
/>
<Route
path="premium"
element={
+6
View File
@@ -78,6 +78,7 @@ export function BackofficeLayout() {
{isClient && <NavItem to="/app/myservices">{isPremium ? 'Ma plateforme' : 'Ma démo'}</NavItem>}
{isAdmin && <NavItem to="/app/demos">Démos</NavItem>}
{isAdmin && <NavItem to="/app/premium">Premium</NavItem>}
{isAdmin && <NavItem to="/app/projects">Projets</NavItem>}
{isAdmin && <NavItem to="/app/codes">Codes</NavItem>}
</HStack>
<Spacer />
@@ -134,6 +135,11 @@ export function BackofficeLayout() {
Premium
</NavItem>
)}
{isAdmin && (
<NavItem to="/app/projects" onClick={onClose} mobile>
Projets
</NavItem>
)}
{isAdmin && (
<NavItem to="/app/codes" onClick={onClose} mobile>
Codes
+174
View File
@@ -0,0 +1,174 @@
import { useState } from 'react'
import {
Button,
FormControl,
FormHelperText,
FormLabel,
Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
Select,
Stack,
useToast,
} from '@chakra-ui/react'
import { api, ApiError } from '../lib/api'
import { PROJECT_MONTHS, monthsLabel } from '../lib/format'
import { PasswordInput } from './PasswordInput'
interface CreateProjectModalProps {
isOpen: boolean
onClose: () => void
onCreated: () => void
}
const MIN_PASSWORD = 12
// Déploiement d'un projet vitrine pour un client : durée de l'abonnement
// (1 à 12 mois), compte admin et nombre d'admins autorisés. Le paiement est
// géré en dehors d'Omnex : l'admin saisit la durée réellement souscrite.
export function CreateProjectModal({ isOpen, onClose, onCreated }: CreateProjectModalProps) {
const toast = useToast()
const [clientName, setClientName] = useState('')
const [months, setMonths] = useState(1)
const [adminUsername, setAdminUsername] = useState('')
const [adminPassword, setAdminPassword] = useState('')
const [adminNumber, setAdminNumber] = useState(1)
const [saving, setSaving] = useState(false)
const usernameOk = /^[A-Za-z0-9]{3,64}$/.test(adminUsername)
const valid =
clientName.trim().length > 0 && usernameOk && adminPassword.length >= MIN_PASSWORD && adminNumber >= 1
const reset = () => {
setClientName('')
setMonths(1)
setAdminUsername('')
setAdminPassword('')
setAdminNumber(1)
}
const handleClose = () => {
if (saving) return
reset()
onClose()
}
const handleCreate = async () => {
setSaving(true)
try {
await api.createProject({
clientName: clientName.trim(),
months,
adminUsername,
adminPassword,
adminNumber,
})
toast({ status: 'success', title: 'Déploiement lancé', description: `Abonnement de ${monthsLabel(months)}` })
reset()
onCreated()
onClose()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Déploiement impossible', description: msg })
} finally {
setSaving(false)
}
}
return (
<Modal isOpen={isOpen} onClose={handleClose} closeOnOverlayClick={!saving}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Déployer un projet</ModalHeader>
<ModalCloseButton isDisabled={saving} />
<ModalBody>
<Stack spacing={4}>
<FormControl isRequired>
<FormLabel fontSize="sm">Client</FormLabel>
<Input
value={clientName}
maxLength={64}
onChange={(e) => setClientName(e.target.value)}
isDisabled={saving}
/>
</FormControl>
<FormControl isRequired>
<FormLabel fontSize="sm">Durée de l'abonnement</FormLabel>
<Select value={months} onChange={(e) => setMonths(Number(e.target.value))} isDisabled={saving}>
{PROJECT_MONTHS.map((m) => (
<option key={m} value={m}>
{monthsLabel(m)}
</option>
))}
</Select>
<FormHelperText>
Durée réellement payée par le client. À l'échéance le projet est suspendu, puis supprimé
après le délai de grâce s'il n'est pas renouvelé.
</FormHelperText>
</FormControl>
<FormControl isRequired isInvalid={adminUsername !== '' && !usernameOk}>
<FormLabel fontSize="sm">Identifiant admin du projet</FormLabel>
<Input
value={adminUsername}
maxLength={64}
onChange={(e) => setAdminUsername(e.target.value)}
isDisabled={saving}
autoComplete="off"
/>
<FormHelperText>3 à 64 caractères alphanumériques.</FormHelperText>
</FormControl>
<FormControl isRequired isInvalid={adminPassword !== '' && adminPassword.length < MIN_PASSWORD}>
<FormLabel fontSize="sm">Mot de passe admin</FormLabel>
<PasswordInput
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
isDisabled={saving}
autoComplete="new-password"
/>
<FormHelperText>{MIN_PASSWORD} caractères minimum. Il n'est pas conservé par Omnex.</FormHelperText>
</FormControl>
<FormControl isRequired>
<FormLabel fontSize="sm">Nombre d'admins autorisés</FormLabel>
<NumberInput
min={1}
max={20}
value={adminNumber}
onChange={(_, n) => setAdminNumber(Number.isNaN(n) ? 1 : n)}
isDisabled={saving}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
<FormHelperText>Nombre maximum de comptes admin dans le projet (1 à 20).</FormHelperText>
</FormControl>
</Stack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={handleClose} isDisabled={saving}>
Annuler
</Button>
<Button colorScheme="primary" onClick={handleCreate} isLoading={saving} isDisabled={!valid}>
Déployer
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react'
import {
Button,
FormControl,
FormHelperText,
FormLabel,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
useToast,
} from '@chakra-ui/react'
import { api, ApiError, type Project } from '../lib/api'
import { PROJECT_MONTHS, monthsLabel } from '../lib/format'
interface ExtendProjectModalProps {
project: Project | null
onClose: () => void
onExtended: () => void
}
// Renouvellement d'un abonnement : 1 à 12 mois ajoutés à l'échéance courante
// (ou à aujourd'hui si elle est dépassée). Relance un projet suspendu.
export function ExtendProjectModal({ project, onClose, onExtended }: ExtendProjectModalProps) {
const toast = useToast()
const [months, setMonths] = useState(1)
const [saving, setSaving] = useState(false)
useEffect(() => {
setMonths(1)
}, [project])
const handleClose = () => {
if (saving) return
onClose()
}
const handleExtend = async () => {
if (!project) return
setSaving(true)
try {
await api.extendProject(project.id, months)
toast({ status: 'success', title: `Abonnement prolongé de ${monthsLabel(months)}` })
onExtended()
onClose()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Renouvellement impossible', description: msg })
} finally {
setSaving(false)
}
}
return (
<Modal isOpen={!!project} onClose={handleClose} closeOnOverlayClick={!saving}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Renouveler l'abonnement</ModalHeader>
<ModalCloseButton isDisabled={saving} />
<ModalBody>
<FormControl>
<FormLabel fontSize="sm">Mois à ajouter — {project?.client_name}</FormLabel>
<Select value={months} onChange={(e) => setMonths(Number(e.target.value))} isDisabled={saving}>
{PROJECT_MONTHS.map((m) => (
<option key={m} value={m}>
{monthsLabel(m)}
</option>
))}
</Select>
<FormHelperText>
{project?.status === 'suspended'
? "Le projet est suspendu : il sera relancé et l'abonnement repart d'aujourd'hui."
: "Les mois s'ajoutent à l'échéance actuelle."}
</FormHelperText>
</FormControl>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={handleClose} isDisabled={saving}>
Annuler
</Button>
<Button colorScheme="primary" onClick={handleExtend} isLoading={saving}>
Renouveler
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}
+48
View File
@@ -58,6 +58,41 @@ export interface Demo {
expires_at: string
}
// Projets vitrine : abonnement de 1 à 12 mois, suspendu à l'échéance puis
// supprimé après un délai de grâce (voir internal/projects côté API).
export type ProjectStatus =
| 'pending'
| 'provisioning'
| 'ready'
| 'suspended'
| 'deleting'
| 'deleted'
| 'failed'
export interface Project {
id: string
client_name: string
status: ProjectStatus
namespace: string
host: string
url: string
admin_username: string
admin_number: number
months_purchased: number
created_at: string
expires_at: string
delete_at: string
suspended_at?: string
}
export interface CreateProjectParams {
clientName: string
months: number
adminUsername: string
adminPassword: string
adminNumber: number
}
export type StorageDriver = 'local' | 's3'
export interface CreateDemoParams {
@@ -215,6 +250,19 @@ export const api = {
request<Demo>('POST', `/demos/${id}/domain`, { domain }),
transferDemoToPremium: (id: string) => request<Demo>('POST', `/demos/${id}/premium`),
listProjects: () => request<{ items: Project[] }>('GET', '/projects'),
createProject: (params: CreateProjectParams) =>
request<Project>('POST', '/projects', {
client_name: params.clientName,
months: params.months,
admin_username: params.adminUsername,
admin_password: params.adminPassword,
admin_number: params.adminNumber,
}),
extendProject: (id: string, months: number) =>
request<Project>('POST', `/projects/${id}/extend`, { months }),
deleteProject: (id: string) => request<Project>('DELETE', `/projects/${id}`),
listCodes: () => request<{ items: CodeBuySub[] }>('GET', '/codes'),
listPremiumUsers: () => request<{ items: PremiumUser[] }>('GET', '/premium'),
createCode: (username: string) =>
+41 -1
View File
@@ -1,4 +1,4 @@
import type { DemoStatus } from './api'
import type { DemoStatus, ProjectStatus } from './api'
// Couleur de badge Chakra par statut de démo.
export function statusColor(s: DemoStatus): string {
@@ -92,3 +92,43 @@ export function formatDate(iso: string): string {
year: 'numeric',
})
}
// Couleur de badge Chakra par statut de projet vitrine.
export function projectStatusColor(s: ProjectStatus): string {
switch (s) {
case 'ready':
return 'green'
case 'provisioning':
case 'pending':
return 'blue'
case 'suspended':
return 'orange'
case 'deleting':
case 'failed':
return 'red'
case 'deleted':
default:
return 'gray'
}
}
// Libellé FR du statut d'un projet vitrine.
export function projectStatusLabel(s: ProjectStatus): string {
const map: Record<ProjectStatus, string> = {
pending: 'En attente',
provisioning: 'Déploiement…',
ready: 'En ligne',
suspended: 'Suspendu',
deleting: 'Suppression…',
deleted: 'Supprimé',
failed: 'Échec',
}
return map[s] ?? s
}
// Durées d'abonnement proposées (1 à 12 mois, contrainte de l'API).
export const PROJECT_MONTHS = Array.from({ length: 12 }, (_, i) => i + 1)
export function monthsLabel(n: number): string {
return `${n} mois`
}
+182
View File
@@ -0,0 +1,182 @@
import { useCallback, useEffect, useState } from 'react'
import {
Badge,
Box,
Button,
Flex,
Heading,
Link,
Spacer,
Spinner,
Table,
TableContainer,
Tbody,
Td,
Text,
Th,
Thead,
Tr,
useToast,
Wrap,
} from '@chakra-ui/react'
import { useNavigate } from 'react-router-dom'
import { api, ApiError, type Project } from '../../lib/api'
import { formatDate, monthsLabel, projectStatusColor, projectStatusLabel, timeRemaining } from '../../lib/format'
import { ConfirmDialog } from '../../components/ConfirmDialog'
import { CreateProjectModal } from '../../components/CreateProjectModal'
import { ExtendProjectModal } from '../../components/ExtendProjectModal'
// Un déploiement en cours => on rafraîchit régulièrement.
const POLL_MS = 5000
// Projets vitrine déployés pour des clients sur abonnement de 1 à 12 mois.
export function Projects() {
const toast = useToast()
const navigate = useNavigate()
const [projects, setProjects] = useState<Project[]>([])
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [toExtend, setToExtend] = useState<Project | null>(null)
const [toDelete, setToDelete] = useState<Project | null>(null)
const load = useCallback(async () => {
try {
const res = await api.listProjects()
setProjects((res.items ?? []).filter((p) => p.status !== 'deleted'))
} catch (err) {
if (err instanceof ApiError && err.status === 401) navigate('/admin/login')
else toast({ status: 'error', title: 'Chargement des projets impossible' })
} finally {
setLoading(false)
}
}, [navigate, toast])
useEffect(() => {
void load()
const id = setInterval(() => void load(), POLL_MS)
return () => clearInterval(id)
}, [load])
const confirmDelete = async () => {
if (!toDelete) return
const p = toDelete
setBusy(p.id)
try {
await api.deleteProject(p.id)
toast({ status: 'success', title: 'Projet supprimé' })
setToDelete(null)
await load()
} catch (err) {
const msg = err instanceof ApiError ? err.message : 'Erreur'
toast({ status: 'error', title: 'Suppression impossible', description: msg })
} finally {
setBusy(null)
}
}
// Échéance affichée : temps restant, ou date de suppression si suspendu.
const deadline = (p: Project) => {
if (p.status === 'suspended') {
return (
<Text color="orange.500" fontSize="sm">
Suppression le {formatDate(p.delete_at)}
</Text>
)
}
return (
<Text fontSize="sm">
{formatDate(p.expires_at)} · {timeRemaining(p.expires_at)}
</Text>
)
}
return (
<Box p={{ base: 4, md: 8 }}>
<Flex mb={6} align="center" gap={3}>
<Heading size="md">Projets</Heading>
<Spacer />
<Button colorScheme="primary" onClick={() => setCreateOpen(true)}>
Déployer un projet
</Button>
</Flex>
{loading ? (
<Spinner />
) : projects.length === 0 ? (
<Text color="gray.500">Aucun projet déployé.</Text>
) : (
<TableContainer>
<Table size="sm">
<Thead>
<Tr>
<Th>Client</Th>
<Th>Statut</Th>
<Th>Adresse</Th>
<Th>Abonnement</Th>
<Th>Échéance</Th>
<Th>Admins</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{projects.map((p) => {
const locked = busy === p.id
const canExtend = p.status === 'ready' || p.status === 'provisioning' || p.status === 'suspended'
return (
<Tr key={p.id}>
<Td>{p.client_name}</Td>
<Td>
<Badge colorScheme={projectStatusColor(p.status)}>{projectStatusLabel(p.status)}</Badge>
</Td>
<Td>
<Link href={p.url} isExternal fontFamily="mono" fontSize="sm">
{p.host}
</Link>
</Td>
<Td>{monthsLabel(p.months_purchased)} au total</Td>
<Td>{deadline(p)}</Td>
<Td>
{p.admin_username} · max {p.admin_number}
</Td>
<Td>
<Wrap justify="flex-end" spacing={2}>
<Button size="xs" colorScheme="primary" isDisabled={!canExtend || locked} onClick={() => setToExtend(p)}>
Renouveler
</Button>
<Button
size="xs"
variant="outline"
colorScheme="red"
isDisabled={p.status === 'deleting' || locked}
onClick={() => setToDelete(p)}
>
Supprimer
</Button>
</Wrap>
</Td>
</Tr>
)
})}
</Tbody>
</Table>
</TableContainer>
)}
<CreateProjectModal isOpen={createOpen} onClose={() => setCreateOpen(false)} onCreated={() => void load()} />
<ExtendProjectModal project={toExtend} onClose={() => setToExtend(null)} onExtended={() => void load()} />
<ConfirmDialog
isOpen={!!toDelete}
title="Supprimer le projet"
confirmLabel="Supprimer définitivement"
confirmColorScheme="red"
isLoading={busy === toDelete?.id}
onConfirm={() => void confirmDelete()}
onClose={() => setToDelete(null)}
>
Le projet de {toDelete?.client_name} sera supprimé immédiatement, avec ses données et ses sauvegardes. Cette
action est irréversible.
</ConfirmDialog>
</Box>
)
}