Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5012e197c3 | |||
| e8a80a2dde | |||
|
|
09cc1633cc | ||
|
|
4cff540ef7 | ||
| 488d90880c | |||
| 9757b6bcfa | |||
| c0d572cf02 | |||
| 051e870170 | |||
| 69a9558524 | |||
| 83a0a6e960 | |||
| 3839143911 | |||
| 95137ce11b | |||
| 3d1945bd73 | |||
| 2de18c9bcb | |||
| 0a72486059 | |||
| 3232c34f85 | |||
| 9c5aed9459 | |||
| 67959fb82a | |||
| 1f7e893fbc | |||
| 9838c97d15 | |||
| f3fc859800 | |||
| b06c952ad2 | |||
| 008451d15e | |||
| eba5817ae5 | |||
| 2deb3639ec | |||
| 370a3a25bc | |||
| d30d87aae1 | |||
| 611a4ef94a | |||
| 7b109b3789 | |||
| ba8b13f361 | |||
| 27fdec6a0a | |||
| 6fa911d194 | |||
| d414f1a46a | |||
| 437e57ed58 | |||
| 7526bd03bf | |||
| 0ac648a700 | |||
| 75a2f5b89a | |||
| 066b9c92bf | |||
| 7c7606db38 | |||
| 005bcfad43 |
2
.env.development
Normal file
2
.env.development
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=http://127.0.0.1:3000
|
||||
|
||||
7
.env.example
Normal file
7
.env.example
Normal file
@ -0,0 +1,7 @@
|
||||
# Vite exposes only variables prefixed with VITE_
|
||||
# Development example
|
||||
# VITE_API_BASE_URL=http://192.168.1.52:3000
|
||||
|
||||
# Production example
|
||||
# VITE_API_BASE_URL=https://api.stackdeploy.example.com
|
||||
|
||||
69
.gitignore
vendored
Normal file
69
.gitignore
vendored
Normal file
@ -0,0 +1,69 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# Vite build output
|
||||
dist/
|
||||
.vite/
|
||||
|
||||
# Local env files
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.production
|
||||
.env
|
||||
|
||||
|
||||
# Editor directories and files
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# OS-specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Optional caches
|
||||
.parcel-cache
|
||||
.cache
|
||||
cache/
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# eslint / prettier cache
|
||||
.eslintcache
|
||||
.prettier-cache
|
||||
|
||||
.gitignorec
|
||||
|
||||
package-lock.json
|
||||
20
DOCKER_README.md
Normal file
20
DOCKER_README.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Docker Development Setup
|
||||
|
||||
To build and run the Docker container locally:
|
||||
|
||||
1. **Build the image**:
|
||||
|
||||
```bash
|
||||
docker build -t frontend-solyti .
|
||||
```
|
||||
|
||||
2. **Run the container**:
|
||||
```bash
|
||||
docker run -p 8080:80 frontend-solyti
|
||||
```
|
||||
|
||||
The application will be accessible at `http://localhost:8080`.
|
||||
|
||||
## Notes on Environment Variables
|
||||
|
||||
The API URL is currently hardcoded to `https://apiautodeploy.firewax.fr` in `src/lib/env.ts`.
|
||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@ -0,0 +1,36 @@
|
||||
# Stage 1: Dependencies
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies only when needed
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Stage 2: Builder
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Runner (Nginx for serving static files)
|
||||
FROM nginx:alpine AS runner
|
||||
|
||||
# Remove default nginx website
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy custom nginx config
|
||||
# We will create this file in the next step
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start Nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
105
Jenkinsfile
vendored
Normal file
105
Jenkinsfile
vendored
Normal file
@ -0,0 +1,105 @@
|
||||
pipeline {
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
}
|
||||
agent any
|
||||
|
||||
environment {
|
||||
SERVICE_NAME = 'AutoDeploy'
|
||||
DOCKER_REGISTRY_URL = 'http://172.75.13.27:3000/corenthin/autodeploy:latest'
|
||||
DOCKER_TAG = '172.75.13.27:3000/corenthin/autodeploy:latest'
|
||||
|
||||
DEPLOY_HOST = '172.75.13.5'
|
||||
DEPLOY_CREDENTIALS_ID = '1000'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout Code') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Docker Image') {
|
||||
steps {
|
||||
script {
|
||||
def shortCommit = sh(returnStdout: true, script: 'git rev-parse --short HEAD').trim()
|
||||
env.IMAGE_TAG = "${env.DOCKER_TAG}"
|
||||
|
||||
echo "Construction de l'image Docker avec le tag : ${env.IMAGE_TAG}"
|
||||
sh "docker build -t ${env.IMAGE_TAG} ."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Push Docker Image') {
|
||||
steps {
|
||||
withCredentials([usernamePassword(credentialsId: 'docker-registry-credentials', passwordVariable: 'DOCKER_PASSWORD', usernameVariable: 'DOCKER_USERNAME')]) {
|
||||
script {
|
||||
def registryHostPort = env.DOCKER_REGISTRY_URL.split('//')[1].split('/')[0]
|
||||
sh "echo \$DOCKER_PASSWORD | docker login -u \$DOCKER_USERNAME --password-stdin 172.75.13.27:3000"
|
||||
}
|
||||
|
||||
|
||||
sh "docker push ${env.IMAGE_TAG}"
|
||||
sh "docker rmi ${env.IMAGE_TAG}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Deploy on VM') {
|
||||
steps {
|
||||
script {
|
||||
def deployScript = """
|
||||
set -e
|
||||
|
||||
echo "Pulling image..."
|
||||
docker pull ${env.IMAGE_TAG}
|
||||
|
||||
# Arrêt et suppression du container existant (force)
|
||||
echo "Suppression du container ${env.SERVICE_NAME} s'il existe..."
|
||||
docker rm -f ${env.SERVICE_NAME} || true
|
||||
|
||||
# 3. Lancement du nouveau container
|
||||
echo "Démarrage du nouveau container..."
|
||||
docker run -d --restart unless-stopped --name ${env.SERVICE_NAME} -p 4444:80 ${env.IMAGE_TAG}
|
||||
|
||||
echo "Vérification du statut..."
|
||||
if [ "\$(docker ps -q -f name=${env.SERVICE_NAME})" ]; then
|
||||
echo "Déploiement réussi !"
|
||||
docker ps -f name=${env.SERVICE_NAME}
|
||||
else
|
||||
echo "ERREUR : Le container ne semble pas avoir démarré."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker image prune -f --filter "until=24h"
|
||||
"""
|
||||
|
||||
sshagent(credentials: [env.DEPLOY_CREDENTIALS_ID]) {
|
||||
sh """
|
||||
ssh -o StrictHostKeyChecking=no corenthin@${env.DEPLOY_HOST} 'bash -s' <<'ENDSSH'
|
||||
${deployScript}
|
||||
ENDSSH
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Cleanup') {
|
||||
steps {
|
||||
cleanWs()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
echo "Pipeline Frontend terminée pour le commit: ${env.IMAGE_TAG}"
|
||||
}
|
||||
failure {
|
||||
echo '!!! ÉCHEC de la pipeline Frontend !!!'
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,11 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Projet Solyti Frontend</title>
|
||||
<title>AutoDeploy</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
37
nginx.conf
Normal file
37
nginx.conf
Normal file
@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to the backend
|
||||
# Proxy API requests to the backend
|
||||
location /api {
|
||||
proxy_pass http://192.168.1.37:4447;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Cache control for static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
2174
package-lock.json
generated
2174
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -4,21 +4,26 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nodemon --watch ./src --exec \"vite\"",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"nodemon": "^3.1.10",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.8",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.4.0",
|
||||
"vite": "^5.4.20"
|
||||
"vite": "^7.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
7
postcss.config.js
Normal file
7
postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
44
src/App.tsx
44
src/App.tsx
@ -1,18 +1,40 @@
|
||||
import { useState } from 'react'
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import { Suspense, lazy } from 'react'
|
||||
import Header from './components/Header'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import AdminRoute from './components/AdminRoute'
|
||||
import LandingRedirect from './components/LandingRedirect'
|
||||
|
||||
const CreateFormation = lazy(() => import('./pages/CreateFormation'))
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const Login = lazy(() => import('@/features/auth/pages/Login'))
|
||||
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'))
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<h1>Bienvenue sur le frontend Solyti</h1>
|
||||
<p>React + TypeScript + Vite</p>
|
||||
<button onClick={() => setCount((c) => c + 1)}>
|
||||
Compteur: {count}
|
||||
</button>
|
||||
</div>
|
||||
<BrowserRouter>
|
||||
<Header />
|
||||
<main className="py-10">
|
||||
<Suspense fallback={
|
||||
<div className="flex h-[50vh] w-full items-center justify-center text-white/50">
|
||||
Chargement...
|
||||
</div>
|
||||
}>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingRedirect />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/formations" element={<CreateFormation />} />
|
||||
<Route element={<AdminRoute />}>
|
||||
<Route path="/admin" element={<AdminDashboard />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</main>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
|
||||
24
src/components/AdminRoute.tsx
Normal file
24
src/components/AdminRoute.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
|
||||
export default function AdminRoute() {
|
||||
const { user, initializing, refresh } = useAuth()
|
||||
const location = useLocation()
|
||||
const [checking, setChecking] = useState(false)
|
||||
const attemptedRefresh = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (initializing || user?.isAdmin || attemptedRefresh.current) return
|
||||
|
||||
attemptedRefresh.current = true
|
||||
setChecking(true)
|
||||
refresh().finally(() => setChecking(false))
|
||||
}, [initializing, refresh, user])
|
||||
|
||||
if (initializing || checking) return null
|
||||
|
||||
if (user?.isAdmin) return <Outlet />
|
||||
|
||||
return <Navigate to="/dashboard" state={{ from: location }} replace />
|
||||
}
|
||||
149
src/components/Header.tsx
Normal file
149
src/components/Header.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
|
||||
export default function Header() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const navigate = useNavigate()
|
||||
const { isAuthenticated, user, logout, initializing, isAdmin } = useAuth()
|
||||
|
||||
const closeMenu = useCallback(() => setOpen(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const onClickAway = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (
|
||||
menuRef.current?.contains(target) ||
|
||||
buttonRef.current?.contains(target)
|
||||
)
|
||||
return
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeMenu()
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onClickAway)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClickAway)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open, closeMenu])
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}, [logout, navigate])
|
||||
|
||||
const handleMobileLogout = useCallback(async () => {
|
||||
closeMenu()
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}, [closeMenu, logout, navigate])
|
||||
|
||||
const navItems = [
|
||||
{ to: '/dashboard', label: 'Tableau de bord' },
|
||||
{ to: '/formations', label: 'Formations' },
|
||||
]
|
||||
|
||||
if (isAdmin) {
|
||||
navItems.push({ to: '/admin', label: 'Administration' })
|
||||
}
|
||||
|
||||
const desktopNavClass = (isActive: boolean) =>
|
||||
[
|
||||
'pb-1 text-sm transition border-b-2',
|
||||
isActive
|
||||
? 'border-accent text-white'
|
||||
: 'border-transparent text-white/80 hover:text-accent hover:border-white/30',
|
||||
].join(' ')
|
||||
|
||||
const mobileNavClass = (isActive: boolean) =>
|
||||
[
|
||||
'block rounded-md px-2 py-1 text-sm transition border-b-2',
|
||||
isActive
|
||||
? 'border-accent text-white'
|
||||
: 'border-transparent text-white/80 hover:text-accent hover:border-white/30',
|
||||
].join(' ')
|
||||
|
||||
const userActions = (
|
||||
<>
|
||||
<span className="text-sm text-white/70">
|
||||
Bonjour {user?.name ?? 'Utilisateur'}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/10 px-3 py-1.5 text-sm font-medium text-white/70 hover:border-accent"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<header className="w-full px-4 sm:px-8 lg:px-12 xl:px-16 2xl:px-24 py-6">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<Link to="/" className="text-2xl font-semibold">
|
||||
AutoDeploy
|
||||
</Link>
|
||||
<nav className="hidden md:flex flex-1 items-center justify-center gap-8">
|
||||
{!initializing && isAuthenticated && navItems.map(item => (
|
||||
<NavLink key={item.to} to={item.to} className={({ isActive }) => desktopNavClass(isActive)}>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="hidden md:flex items-center gap-4">
|
||||
{!initializing && isAuthenticated && userActions}
|
||||
</div>
|
||||
<button
|
||||
aria-label="Ouvrir le menu"
|
||||
className="ml-auto md:hidden inline-flex items-center justify-center rounded-lg border border-white/10 bg-white/5 p-2 hover:border-accent"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
ref={buttonRef}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M3 6h18M3 12h18M3 18h18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="md:hidden mt-3 rounded-xl border border-white/10 bg-white/5 backdrop-blur p-3 space-y-2"
|
||||
>
|
||||
{!initializing && (
|
||||
isAuthenticated ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 pb-2">
|
||||
{navItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => mobileNavClass(isActive)}
|
||||
onClick={closeMenu}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleMobileLogout}
|
||||
className="block w-full text-left text-accent"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
12
src/components/LandingRedirect.tsx
Normal file
12
src/components/LandingRedirect.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
|
||||
export default function LandingRedirect() {
|
||||
const { initializing, isAuthenticated, isAdmin } = useAuth()
|
||||
|
||||
if (initializing) return null
|
||||
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />
|
||||
|
||||
return <Navigate to={isAdmin ? '/admin' : '/dashboard'} replace />
|
||||
}
|
||||
24
src/components/ProtectedRoute.tsx
Normal file
24
src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
|
||||
export default function ProtectedRoute() {
|
||||
const { isAuthenticated, initializing, refresh } = useAuth()
|
||||
const location = useLocation()
|
||||
const [checking, setChecking] = useState(false)
|
||||
const attemptedRefresh = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializing && !isAuthenticated && !attemptedRefresh.current) {
|
||||
attemptedRefresh.current = true
|
||||
setChecking(true)
|
||||
refresh().finally(() => setChecking(false))
|
||||
}
|
||||
}, [initializing, isAuthenticated, refresh])
|
||||
|
||||
if (initializing || checking) return null
|
||||
|
||||
return isAuthenticated
|
||||
? <Outlet />
|
||||
: <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
116
src/context/AuthContext.tsx
Normal file
116
src/context/AuthContext.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { me as fetchCurrentUser, logout as requestLogout } from '@/features/auth/api'
|
||||
import { resolveDisplayName, resolveIsAdmin } from '@/features/auth/utils'
|
||||
|
||||
export type User = {
|
||||
id?: string
|
||||
name: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
display_name?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
isAdmin?: boolean
|
||||
role?: string
|
||||
}
|
||||
|
||||
|
||||
|
||||
type AuthContextValue = {
|
||||
user: User | null
|
||||
login: (user?: Partial<User> | null) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
refresh: () => Promise<User | null>
|
||||
isAuthenticated: boolean
|
||||
isAdmin: boolean
|
||||
initializing: boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [initializing, setInitializing] = useState(true)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchCurrentUser()
|
||||
const raw = (data as { user?: Partial<User> }).user ?? (data as Partial<User>)
|
||||
if (raw) {
|
||||
const normalized: User = {
|
||||
id: raw.id,
|
||||
name: resolveDisplayName(raw),
|
||||
email: raw.email,
|
||||
displayName: raw.displayName ?? raw.name,
|
||||
firstName: raw.firstName,
|
||||
lastName: raw.lastName,
|
||||
isAdmin: resolveIsAdmin(raw),
|
||||
role: raw.role,
|
||||
}
|
||||
setUser(normalized)
|
||||
return normalized
|
||||
}
|
||||
setUser(null)
|
||||
return null
|
||||
} catch {
|
||||
setUser(null)
|
||||
return null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh().finally(() => setInitializing(false))
|
||||
}, [refresh])
|
||||
|
||||
const login = useCallback(
|
||||
async (nextUser?: Partial<User> | null) => {
|
||||
if (nextUser) {
|
||||
setUser({
|
||||
id: nextUser.id,
|
||||
name: resolveDisplayName(nextUser),
|
||||
email: nextUser.email,
|
||||
displayName: nextUser.displayName ?? nextUser.name,
|
||||
firstName: nextUser.firstName,
|
||||
lastName: nextUser.lastName,
|
||||
isAdmin: resolveIsAdmin(nextUser),
|
||||
role: nextUser.role,
|
||||
})
|
||||
}
|
||||
try {
|
||||
await refresh()
|
||||
} catch {
|
||||
|
||||
}
|
||||
},
|
||||
[refresh],
|
||||
)
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await requestLogout()
|
||||
} finally {
|
||||
setUser(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
refresh,
|
||||
isAuthenticated: !!user,
|
||||
isAdmin: !!user?.isAdmin,
|
||||
initializing,
|
||||
}),
|
||||
[user, login, logout, refresh, initializing],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) throw new Error('useAuth must be used within an AuthProvider')
|
||||
return context
|
||||
}
|
||||
113
src/context/ToastContext.tsx
Normal file
113
src/context/ToastContext.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
type ToastVariant = 'success' | 'error' | 'info'
|
||||
|
||||
type ShowToastOptions = {
|
||||
message: string
|
||||
variant?: ToastVariant
|
||||
duration?: number
|
||||
}
|
||||
|
||||
type ToastItem = {
|
||||
id: number
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
}
|
||||
|
||||
type ToastContextValue = {
|
||||
show: (options: ShowToastOptions) => number
|
||||
success: (message: string, duration?: number) => number
|
||||
error: (message: string, duration?: number) => number
|
||||
info: (message: string, duration?: number) => number
|
||||
dismiss: (id: number) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | undefined>(undefined)
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
const idRef = useRef(0)
|
||||
|
||||
const dismiss = useCallback((id: number) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id))
|
||||
}, [])
|
||||
|
||||
const show = useCallback(
|
||||
({ message, variant = 'info', duration = 3500 }: ShowToastOptions) => {
|
||||
const id = ++idRef.current
|
||||
setToasts(prev => [...prev, { id, message, variant }])
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
dismiss(id)
|
||||
}, duration)
|
||||
}
|
||||
return id
|
||||
},
|
||||
[dismiss],
|
||||
)
|
||||
|
||||
const success = useCallback(
|
||||
(message: string, duration?: number) => show({ message, duration, variant: 'success' }),
|
||||
[show],
|
||||
)
|
||||
|
||||
const error = useCallback(
|
||||
(message: string, duration?: number) => show({ message, duration, variant: 'error' }),
|
||||
[show],
|
||||
)
|
||||
|
||||
const info = useCallback(
|
||||
(message: string, duration?: number) => show({ message, duration, variant: 'info' }),
|
||||
[show],
|
||||
)
|
||||
|
||||
const value = useMemo<ToastContextValue>(
|
||||
() => ({
|
||||
show,
|
||||
success,
|
||||
error,
|
||||
info,
|
||||
dismiss,
|
||||
}),
|
||||
[dismiss, error, info, show, success],
|
||||
)
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
{createPortal(
|
||||
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[1000] flex flex-col items-center gap-3 px-4">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`pointer-events-auto flex w-full max-w-md items-start gap-3 rounded-xl border px-4 py-3 text-sm shadow-lg transition
|
||||
${
|
||||
toast.variant === 'success'
|
||||
? 'border-emerald-400/40 bg-emerald-400/10 text-emerald-100'
|
||||
: toast.variant === 'error'
|
||||
? 'border-red-400/40 bg-red-500/15 text-red-100'
|
||||
: 'border-white/15 bg-white/10 text-white/90'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1">{toast.message}</span>
|
||||
<button
|
||||
onClick={() => dismiss(toast.id)}
|
||||
className="rounded-md border border-white/15 bg-black/20 px-2 py-0.5 text-xs uppercase tracking-wide text-white/60 hover:text-white"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) throw new Error('useToast must be used within a ToastProvider')
|
||||
return context
|
||||
}
|
||||
43
src/features/admin/api.ts
Normal file
43
src/features/admin/api.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { del, get, post, put } from '@/lib/http'
|
||||
|
||||
export type AdminUser = {
|
||||
id: string
|
||||
email: string
|
||||
displayName?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
isAdmin: boolean
|
||||
role?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type CreateUserPayload = {
|
||||
email: string
|
||||
displayName: string
|
||||
password: string
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export type UpdateUserPayload = {
|
||||
email?: string
|
||||
displayName?: string
|
||||
password?: string
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
export async function fetchUsers() {
|
||||
return get('/admin/users') as Promise<AdminUser[]>
|
||||
}
|
||||
|
||||
export async function createUser(payload: CreateUserPayload) {
|
||||
return post('/admin/users', payload) as Promise<AdminUser>
|
||||
}
|
||||
|
||||
export async function updateUser(id: string, payload: UpdateUserPayload) {
|
||||
return put(`/admin/users/${id}`, payload) as Promise<AdminUser>
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string) {
|
||||
return del(`/admin/users/${id}`) as Promise<void>
|
||||
}
|
||||
122
src/features/admin/components/AdminUserCreateForm.tsx
Normal file
122
src/features/admin/components/AdminUserCreateForm.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { type CreateUserPayload } from '@/features/admin/api'
|
||||
|
||||
type Props = {
|
||||
busy?: boolean
|
||||
onSubmit: (payload: CreateUserPayload) => Promise<{ ok: boolean; message?: string }>
|
||||
}
|
||||
|
||||
const initialForm: CreateUserPayload & { password: string } = {
|
||||
displayName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
isAdmin: false,
|
||||
}
|
||||
|
||||
export function AdminUserCreateForm({ busy, onSubmit }: Props) {
|
||||
const [form, setForm] = useState(initialForm)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleChange = (field: keyof typeof form) => (value: string | boolean) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
|
||||
const payload = {
|
||||
displayName: form.displayName.trim(),
|
||||
email: form.email.trim(),
|
||||
password: form.password,
|
||||
isAdmin: form.isAdmin,
|
||||
}
|
||||
|
||||
if (!payload.displayName) {
|
||||
setError('Le nom est requis')
|
||||
return
|
||||
}
|
||||
if (!payload.email) {
|
||||
setError("L’email est requis")
|
||||
return
|
||||
}
|
||||
if (!payload.password) {
|
||||
setError('Le mot de passe est requis')
|
||||
return
|
||||
}
|
||||
|
||||
const result = await onSubmit(payload)
|
||||
if (!result.ok) {
|
||||
setError(result.message ?? "Impossible de créer l'utilisateur")
|
||||
return
|
||||
}
|
||||
|
||||
setForm(initialForm)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-1">
|
||||
<label className="mb-1 block text-sm font-medium text-white/80">Nom affiché</label>
|
||||
<input
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
value={form.displayName}
|
||||
onChange={event => handleChange('displayName')(event.target.value)}
|
||||
placeholder="Nom et prénom"
|
||||
autoComplete="name"
|
||||
maxLength={120}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1">
|
||||
<label className="mb-1 block text-sm font-medium text-white/80">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
value={form.email}
|
||||
onChange={event => handleChange('email')(event.target.value)}
|
||||
placeholder="utilisateur@example.com"
|
||||
autoComplete="email"
|
||||
maxLength={254}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-1">
|
||||
<label className="mb-1 block text-sm font-medium text-white/80">Mot de passe temporaire</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
value={form.password}
|
||||
onChange={event => handleChange('password')(event.target.value)}
|
||||
placeholder="••••••"
|
||||
autoComplete="new-password"
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:col-span-1 pt-6">
|
||||
<input
|
||||
id="create-is-admin"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-white/20 bg-white/5"
|
||||
checked={form.isAdmin}
|
||||
onChange={event => handleChange('isAdmin')(event.target.checked)}
|
||||
/>
|
||||
<label htmlFor="create-is-admin" className="text-sm text-white/80">
|
||||
Administrateur
|
||||
</label>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="md:col-span-2 text-sm text-red-300">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="md:col-span-2 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary px-6 disabled:opacity-60"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? 'Création…' : 'Créer le compte'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
132
src/features/admin/components/AdminUsersTable.tsx
Normal file
132
src/features/admin/components/AdminUsersTable.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { useState } from 'react'
|
||||
import type { AdminUser, UpdateUserPayload } from '@/features/admin/api'
|
||||
import { EditUserRow } from './EditUserRow'
|
||||
import { UserRow } from './UserRow'
|
||||
|
||||
type Result =
|
||||
| { ok: true; message?: string }
|
||||
| { ok: false; message: string }
|
||||
|
||||
type Props = {
|
||||
users: AdminUser[]
|
||||
loading?: boolean
|
||||
savingId?: string | null
|
||||
deletingId?: string | null
|
||||
onUpdate: (id: string, payload: UpdateUserPayload) => Promise<Result>
|
||||
onToggleAdmin: (user: AdminUser) => Promise<Result>
|
||||
onDelete: (user: AdminUser) => Promise<Result>
|
||||
}
|
||||
|
||||
export function AdminUsersTable({
|
||||
users,
|
||||
loading,
|
||||
savingId,
|
||||
deletingId,
|
||||
onUpdate,
|
||||
onToggleAdmin,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editError, setEditError] = useState<string | null>(null)
|
||||
|
||||
const hasUsers = users.length > 0
|
||||
|
||||
const handleSave = async (id: string, data: { displayName: string; email: string; password?: string; isAdmin: boolean }) => {
|
||||
const result = await onUpdate(id, data)
|
||||
if (!result.ok) {
|
||||
setEditError(result.message ?? "Impossible de mettre à jour l'utilisateur")
|
||||
} else {
|
||||
setEditingId(null)
|
||||
setEditError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleAdmin = async (candidate: AdminUser) => {
|
||||
setEditError(null)
|
||||
const result = await onToggleAdmin(candidate)
|
||||
if (!result.ok) {
|
||||
setEditError(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (candidate: AdminUser) => {
|
||||
setEditError(null)
|
||||
const confirmation = window.confirm(
|
||||
`Supprimer ${candidate.displayName ?? candidate.email} ?`,
|
||||
)
|
||||
if (!confirmation) return
|
||||
|
||||
const result = await onDelete(candidate)
|
||||
if (!result.ok) {
|
||||
setEditError(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
|
||||
<div className="mb-6 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Utilisateurs existants</h2>
|
||||
<p className="text-sm text-white/70">
|
||||
Modifier les informations, définir le rôle administrateur ou supprimer un compte.
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm text-white/60">
|
||||
{users.length} utilisateur{users.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-sm text-white/60">Chargement des utilisateurs…</p>
|
||||
) : !hasUsers ? (
|
||||
<p className="text-sm text-white/60">Aucun utilisateur pour le moment.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] table-auto border-separate border-spacing-y-2 text-sm text-white/80">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-white/50">
|
||||
<tr>
|
||||
<th className="rounded-l-lg bg-white/5 px-4 py-3 font-normal">Nom</th>
|
||||
<th className="bg-white/5 px-4 py-3 font-normal">Email</th>
|
||||
<th className="bg-white/5 px-4 py-3 font-normal">Rôle</th>
|
||||
<th className="rounded-r-lg bg-white/5 px-4 py-3 font-normal text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(candidate => {
|
||||
const isEditing = editingId === candidate.id
|
||||
|
||||
return (
|
||||
<tr key={candidate.id} className="rounded-lg bg-white/5">
|
||||
{isEditing ? (
|
||||
<EditUserRow
|
||||
user={candidate}
|
||||
isSaving={savingId === candidate.id}
|
||||
onSave={(data) => handleSave(candidate.id, data)}
|
||||
onCancel={() => {
|
||||
setEditingId(null)
|
||||
setEditError(null)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<UserRow
|
||||
user={candidate}
|
||||
isSaving={savingId === candidate.id}
|
||||
isDeleting={deletingId === candidate.id}
|
||||
onEdit={() => {
|
||||
setEditingId(candidate.id)
|
||||
setEditError(null)
|
||||
}}
|
||||
onToggleAdmin={() => handleToggleAdmin(candidate)}
|
||||
onDelete={() => handleDelete(candidate)}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{editError && <p className="mt-4 text-sm text-red-300 text-center">{editError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
105
src/features/admin/components/EditUserRow.tsx
Normal file
105
src/features/admin/components/EditUserRow.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { useState } from 'react'
|
||||
import type { AdminUser } from '@/features/admin/api'
|
||||
|
||||
type Props = {
|
||||
user: AdminUser
|
||||
isSaving: boolean
|
||||
onSave: (data: { displayName: string; email: string; password?: string; isAdmin: boolean }) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function EditUserRow({ user, isSaving, onSave, onCancel }: Props) {
|
||||
const [displayName, setDisplayName] = useState(user.displayName ?? '')
|
||||
const [email, setEmail] = useState(user.email)
|
||||
const [password, setPassword] = useState('')
|
||||
const [isAdmin, setIsAdmin] = useState(user.isAdmin)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmedName = displayName.trim()
|
||||
const trimmedEmail = email.trim()
|
||||
|
||||
if (!trimmedName) {
|
||||
setError('Le nom est requis')
|
||||
return
|
||||
}
|
||||
if (!trimmedEmail) {
|
||||
setError("L’email est requis")
|
||||
return
|
||||
}
|
||||
|
||||
onSave({
|
||||
displayName: trimmedName,
|
||||
email: trimmedEmail,
|
||||
password: password || undefined,
|
||||
isAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<td className="rounded-l-lg px-4 py-3 align-top">
|
||||
<input
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
autoComplete="name"
|
||||
maxLength={120}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<input
|
||||
type="email"
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
maxLength={254}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="password"
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm outline-none focus:border-accent"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Nouveau mot de passe (optionnel)"
|
||||
autoComplete="new-password"
|
||||
maxLength={64}
|
||||
/>
|
||||
<label className="inline-flex items-center gap-2 text-xs text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-white/20 bg-white/5"
|
||||
checked={isAdmin}
|
||||
onChange={e => setIsAdmin(e.target.checked)}
|
||||
/>
|
||||
Administrateur
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td className="rounded-r-lg px-4 py-3 text-right align-top">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/10 px-4 py-2 text-xs font-medium text-white/70 hover:border-accent disabled:opacity-60"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary px-4 disabled:opacity-60"
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Enregistrement…' : 'Enregistrer'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="mt-2 text-xs text-red-300">{error}</p>}
|
||||
</td>
|
||||
</>
|
||||
)
|
||||
}
|
||||
68
src/features/admin/components/TemplatesTable.tsx
Normal file
68
src/features/admin/components/TemplatesTable.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useTemplates } from '@/features/admin/hooks/useTemplates'
|
||||
|
||||
export function TemplatesTable() {
|
||||
const { templates, loading, error, reload } = useTemplates()
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...templates].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[templates],
|
||||
)
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
|
||||
<div className="mb-6 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Templates Proxmox</h2>
|
||||
<p className="text-sm text-white/70">Valeurs par défaut utilisées lors de la création des formations.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void reload()}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/10 px-4 py-2 text-sm text-white/70 hover:border-accent disabled:opacity-60"
|
||||
disabled={loading}
|
||||
>
|
||||
Rafraîchir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-400/40 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] table-auto border-separate border-spacing-y-2">
|
||||
<thead className="text-left text-xs uppercase tracking-wide text-white/50">
|
||||
<tr>
|
||||
<th className="rounded-l-lg bg-white/5 px-4 py-3 font-normal">Nom</th>
|
||||
<th className="bg-white/5 px-4 py-3 font-normal">OS</th>
|
||||
<th className="bg-white/5 px-4 py-3 font-normal">Disque par défaut</th>
|
||||
<th className="rounded-r-lg bg-white/5 px-4 py-3 font-normal">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-4 text-center text-sm text-white/60">Chargement…</td>
|
||||
</tr>
|
||||
) : sorted.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-4 text-center text-sm text-white/60">Aucun template détecté.</td>
|
||||
</tr>
|
||||
) : (
|
||||
sorted.map(template => (
|
||||
<tr key={template.name} className="rounded-lg bg-white/5 text-sm text-white/80">
|
||||
<td className="rounded-l-lg px-4 py-3 font-medium text-white">{template.name}</td>
|
||||
<td className="px-4 py-3 capitalize">{template.os}</td>
|
||||
<td className="px-4 py-3">{template.defaultDiskSizeGb} Go</td>
|
||||
<td className="rounded-r-lg px-4 py-3 text-white/70">{template.description || '—'}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
56
src/features/admin/components/UserRow.tsx
Normal file
56
src/features/admin/components/UserRow.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import type { AdminUser } from '@/features/admin/api'
|
||||
|
||||
type Props = {
|
||||
user: AdminUser
|
||||
isSaving: boolean
|
||||
isDeleting: boolean
|
||||
onEdit: () => void
|
||||
onToggleAdmin: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function UserRow({ user, isSaving, isDeleting, onEdit, onToggleAdmin, onDelete }: Props) {
|
||||
return (
|
||||
<>
|
||||
<td className="rounded-l-lg px-4 py-3 font-medium text-white">
|
||||
{user.displayName ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">{user.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${user.isAdmin ? 'bg-emerald-400/10 text-emerald-200' : 'bg-white/10 text-white/70'
|
||||
}`}
|
||||
>
|
||||
{user.isAdmin ? 'Administrateur' : 'Utilisateur'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="rounded-r-lg px-4 py-3 text-right">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/10 px-4 py-2 text-xs font-medium text-white/70 hover:border-accent"
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/10 px-4 py-2 text-xs font-medium text-white/70 hover:border-accent disabled:opacity-60"
|
||||
onClick={onToggleAdmin}
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{user.isAdmin ? 'Retirer les droits' : 'Rendre admin'}
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center justify-center rounded-lg border border-red-400/40 px-4 py-2 text-xs font-medium text-red-300 hover:bg-red-500/10 disabled:opacity-60"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Suppression…' : 'Supprimer'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
)
|
||||
}
|
||||
145
src/features/admin/hooks/useAdminUsers.ts
Normal file
145
src/features/admin/hooks/useAdminUsers.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
createUser as createUserRequest,
|
||||
deleteUser as deleteUserRequest,
|
||||
fetchUsers,
|
||||
updateUser as updateUserRequest,
|
||||
type AdminUser,
|
||||
type CreateUserPayload,
|
||||
type UpdateUserPayload,
|
||||
} from '@/features/admin/api'
|
||||
import { useToast } from '@/context/ToastContext'
|
||||
|
||||
type Result =
|
||||
| { ok: true; message?: string }
|
||||
| { ok: false; message: string }
|
||||
|
||||
export function useAdminUsers() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [savingId, setSavingId] = useState<string | null>(null)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const toast = useToast()
|
||||
|
||||
const sortedUsers = useMemo(
|
||||
() =>
|
||||
[...users].sort((a, b) => {
|
||||
if (a.isAdmin !== b.isAdmin) return a.isAdmin ? -1 : 1
|
||||
return (a.displayName ?? '').localeCompare(b.displayName ?? '')
|
||||
}),
|
||||
[users],
|
||||
)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetchUsers()
|
||||
setUsers(response)
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Erreur lors du chargement des utilisateurs'
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const create = useCallback(
|
||||
async (payload: CreateUserPayload): Promise<Result> => {
|
||||
setCreating(true)
|
||||
try {
|
||||
const created = await createUserRequest(payload)
|
||||
setUsers(prev => [...prev, created])
|
||||
toast.success(`Utilisateur ${created.displayName ?? created.email} créé`)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Impossible de créer l'utilisateur"
|
||||
toast.error(message)
|
||||
return { ok: false, message }
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
)
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string, payload: UpdateUserPayload, successMessage?: string): Promise<Result> => {
|
||||
setSavingId(id)
|
||||
try {
|
||||
const updated = await updateUserRequest(id, payload)
|
||||
setUsers(prev => prev.map(user => (user.id === updated.id ? updated : user)))
|
||||
const label = updated.displayName ?? updated.email
|
||||
toast.success(successMessage ?? `Utilisateur ${label} mis à jour`)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Impossible de mettre à jour l'utilisateur"
|
||||
toast.error(message)
|
||||
return { ok: false, message }
|
||||
} finally {
|
||||
setSavingId(null)
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
)
|
||||
|
||||
const toggleAdmin = useCallback(
|
||||
async (candidate: AdminUser): Promise<Result> => {
|
||||
const nextIsAdmin = !candidate.isAdmin
|
||||
return update(
|
||||
candidate.id,
|
||||
{
|
||||
displayName: candidate.displayName ?? '',
|
||||
email: candidate.email,
|
||||
isAdmin: nextIsAdmin,
|
||||
},
|
||||
`${candidate.displayName ?? candidate.email} est désormais ${nextIsAdmin ? 'administrateur' : 'utilisateur'}`,
|
||||
)
|
||||
},
|
||||
[update],
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
async (candidate: AdminUser): Promise<Result> => {
|
||||
setDeletingId(candidate.id)
|
||||
try {
|
||||
await deleteUserRequest(candidate.id)
|
||||
setUsers(prev => prev.filter(user => user.id !== candidate.id))
|
||||
toast.success(`Utilisateur ${candidate.displayName ?? candidate.email} supprimé`)
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Impossible de supprimer l'utilisateur"
|
||||
toast.error(message)
|
||||
return { ok: false, message }
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
)
|
||||
|
||||
return {
|
||||
users: sortedUsers,
|
||||
loading,
|
||||
error,
|
||||
creating,
|
||||
savingId,
|
||||
deletingId,
|
||||
refresh: load,
|
||||
createUser: create,
|
||||
updateUser: update,
|
||||
toggleAdmin,
|
||||
deleteUser: remove,
|
||||
}
|
||||
}
|
||||
28
src/features/admin/hooks/useTemplates.ts
Normal file
28
src/features/admin/hooks/useTemplates.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchTemplates, type TemplateInfo } from '@/features/formations/api'
|
||||
|
||||
export function useTemplates() {
|
||||
const [templates, setTemplates] = useState<TemplateInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchTemplates()
|
||||
setTemplates(data)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Impossible de charger les templates'
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
return { templates, loading, error, reload: load }
|
||||
}
|
||||
30
src/features/auth/api.ts
Normal file
30
src/features/auth/api.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { get, post } from '@/lib/http'
|
||||
|
||||
export type LoginPayload = { email: string; password: string }
|
||||
|
||||
export type AuthUser = {
|
||||
id?: string
|
||||
name?: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
isAdmin?: boolean
|
||||
role?: string
|
||||
}
|
||||
|
||||
export type AuthResponse = {
|
||||
user?: AuthUser
|
||||
} & AuthUser
|
||||
|
||||
export async function login(payload: LoginPayload) {
|
||||
return post('/auth/login', payload) as Promise<AuthResponse>
|
||||
}
|
||||
|
||||
export async function me() {
|
||||
return get('/auth/me') as Promise<AuthResponse['user'] | { user: AuthResponse['user'] }>
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
return post('/auth/logout') as Promise<void>
|
||||
}
|
||||
22
src/features/auth/components/AuthCard.tsx
Normal file
22
src/features/auth/components/AuthCard.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type AuthCardProps = {
|
||||
title: string
|
||||
children: ReactNode
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
export function AuthCard({ title, subtitle, children }: AuthCardProps) {
|
||||
return (
|
||||
<div className="min-h-[60vh] flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
|
||||
<div className="mb-6 text-center space-y-1">
|
||||
<h2 className="text-xl font-semibold">{title}</h2>
|
||||
{subtitle && <p className="text-sm text-white/70">{subtitle}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
18
src/features/auth/components/AuthTextField.tsx
Normal file
18
src/features/auth/components/AuthTextField.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import type { InputHTMLAttributes } from 'react'
|
||||
|
||||
type Props = {
|
||||
label: string
|
||||
} & InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
export function AuthTextField({ label, className = '', ...props }: Props) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1 text-left">
|
||||
<span className="text-sm text-white/80">{label}</span>
|
||||
<input
|
||||
className={`w-full rounded-lg bg-white/5 text-fg border border-white/10 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-accent ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
35
src/features/auth/hooks/useAuthRedirect.ts
Normal file
35
src/features/auth/hooks/useAuthRedirect.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth, type User } from '@/context/AuthContext'
|
||||
|
||||
type Options = {
|
||||
whenAuthenticated?: string | ((user: User | null) => string)
|
||||
enableRefresh?: boolean
|
||||
autoNavigate?: boolean
|
||||
}
|
||||
|
||||
export function useAuthRedirect({ whenAuthenticated = '/dashboard', enableRefresh = true, autoNavigate = true }: Options = {}) {
|
||||
const { isAuthenticated, initializing, refresh, user } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const triedRefresh = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (initializing) return
|
||||
|
||||
if (!isAuthenticated && enableRefresh && !triedRefresh.current) {
|
||||
triedRefresh.current = true
|
||||
refresh().catch(() => undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (isAuthenticated && autoNavigate) {
|
||||
const destination =
|
||||
typeof whenAuthenticated === 'function'
|
||||
? whenAuthenticated(user ?? null)
|
||||
: whenAuthenticated
|
||||
navigate(destination, { replace: true })
|
||||
}
|
||||
}, [autoNavigate, enableRefresh, initializing, isAuthenticated, navigate, refresh, whenAuthenticated, user])
|
||||
|
||||
return { initializing, isAuthenticated }
|
||||
}
|
||||
74
src/features/auth/pages/Login.tsx
Normal file
74
src/features/auth/pages/Login.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { login as loginRequest } from '@/features/auth/api'
|
||||
import { AuthCard } from '@/features/auth/components/AuthCard'
|
||||
import { AuthTextField } from '@/features/auth/components/AuthTextField'
|
||||
import { useAuthRedirect } from '@/features/auth/hooks/useAuthRedirect'
|
||||
import { resolveDisplayName, resolveIsAdmin } from '@/features/auth/utils'
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
const { login: setAuthUser } = useAuth()
|
||||
const { initializing } = useAuthRedirect({
|
||||
whenAuthenticated: user => (user?.isAdmin ? '/admin' : '/dashboard'),
|
||||
})
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await loginRequest({ email: email.trim(), password })
|
||||
const user = res.user ?? res ?? {}
|
||||
const name = resolveDisplayName(user, email)
|
||||
const isAdmin = resolveIsAdmin(user)
|
||||
await setAuthUser({
|
||||
...user,
|
||||
name,
|
||||
email: user.email ?? email.trim(),
|
||||
isAdmin,
|
||||
})
|
||||
navigate(isAdmin ? '/admin' : '/dashboard', { replace: true })
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Erreur de connexion')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (initializing && !loading) return null
|
||||
|
||||
return (
|
||||
<AuthCard title="Connexion">
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||
<AuthTextField
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={event => setEmail(event.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<AuthTextField
|
||||
label="Mot de passe"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={event => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button className="btn-primary w-full disabled:opacity-60" disabled={loading} type="submit">
|
||||
{loading ? 'Connexion…' : 'Se connecter'}
|
||||
</button>
|
||||
</form>
|
||||
</AuthCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
28
src/features/auth/utils.ts
Normal file
28
src/features/auth/utils.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export type AuthUserCandidate = {
|
||||
id?: string
|
||||
name?: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
display_name?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
isAdmin?: boolean
|
||||
role?: string
|
||||
is_admin?: boolean
|
||||
}
|
||||
|
||||
export const resolveDisplayName = (user: AuthUserCandidate, fallbackEmail?: string) =>
|
||||
user.displayName ||
|
||||
user.name ||
|
||||
[user.firstName, user.lastName].filter(Boolean).join(' ').trim() ||
|
||||
(user.email ? user.email.split('@')[0] : '') ||
|
||||
(fallbackEmail ? fallbackEmail.split('@')[0] : '') ||
|
||||
'Utilisateur'
|
||||
|
||||
export const resolveIsAdmin = (user: AuthUserCandidate) => {
|
||||
if (typeof user.isAdmin === 'boolean') return user.isAdmin
|
||||
if (typeof user.role === 'string') return user.role.toLowerCase() === 'admin'
|
||||
const legacy = user.is_admin
|
||||
if (typeof legacy === 'boolean') return legacy
|
||||
return false
|
||||
}
|
||||
123
src/features/formations/api.ts
Normal file
123
src/features/formations/api.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { del, get, post } from '@/lib/http'
|
||||
|
||||
export type TerraformVm = {
|
||||
name: string
|
||||
vmid: number
|
||||
cores: number
|
||||
memory: number
|
||||
disk_size: number
|
||||
diskSizeGb?: number
|
||||
diskSize?: number
|
||||
template: string
|
||||
ip: string
|
||||
networkInterfaces: Array<{
|
||||
ip: string
|
||||
gateway: string
|
||||
bridge: string
|
||||
model: string
|
||||
}>
|
||||
rdpUsername?: string
|
||||
rdpPassword?: string
|
||||
}
|
||||
|
||||
export type TerraformPayload = {
|
||||
formationId: string
|
||||
model: string
|
||||
vms: TerraformVm[]
|
||||
guacamole?: GuacamolePayload
|
||||
}
|
||||
|
||||
export type GuacamolePayload = {
|
||||
groupName: string
|
||||
users: Array<{
|
||||
username: string
|
||||
password: string
|
||||
displayName?: string
|
||||
ip?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type ApplyTerraformResponse = {
|
||||
status: 'queued' | 'processing' | 'completed' | 'failed'
|
||||
formationId: string
|
||||
remotePath?: string
|
||||
}
|
||||
|
||||
export async function applyTerraform(payload: TerraformPayload) {
|
||||
return post('/terraform/apply', payload) as Promise<ApplyTerraformResponse>
|
||||
}
|
||||
|
||||
export type TerraformFormationRecord = {
|
||||
id: string
|
||||
formationId: string
|
||||
remotePath: string
|
||||
createdAt: string
|
||||
status?: 'queued' | 'processing' | 'completed' | 'failed'
|
||||
}
|
||||
|
||||
export type TerraformFormationsResponse = {
|
||||
formations: TerraformFormationRecord[]
|
||||
}
|
||||
|
||||
export async function fetchFormations() {
|
||||
const res = await get('/terraform/formations') as TerraformFormationsResponse
|
||||
const list = res.formations ?? []
|
||||
return list.map(f => ({
|
||||
...f,
|
||||
status: f.status ?? (f as any).state // Fallback if backend uses 'state'
|
||||
}))
|
||||
}
|
||||
|
||||
export async function deleteFormation(formationId: string) {
|
||||
await del(`/terraform/formations/${encodeURIComponent(formationId)}`)
|
||||
}
|
||||
|
||||
type RawTemplateInfo = {
|
||||
name: string
|
||||
defaultDiskSizeGb?: number
|
||||
diskSizeGb?: number
|
||||
default_disk_size_gb?: number
|
||||
disk_size_gb?: number
|
||||
os?: string
|
||||
model?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type TemplateInfo = {
|
||||
name: string
|
||||
defaultDiskSizeGb: number
|
||||
os: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
type TemplatesResponse = {
|
||||
templates: RawTemplateInfo[]
|
||||
}
|
||||
|
||||
const MIN_DISK_SIZE = 1
|
||||
|
||||
const normalizeTemplate = (template: RawTemplateInfo): TemplateInfo => {
|
||||
const rawSize =
|
||||
template.defaultDiskSizeGb ??
|
||||
template.diskSizeGb ??
|
||||
template.default_disk_size_gb ??
|
||||
template.disk_size_gb
|
||||
|
||||
const size = typeof rawSize === 'number' && !Number.isNaN(rawSize) ? Math.max(MIN_DISK_SIZE, rawSize) : 0
|
||||
|
||||
const rawOs = template.os ?? template.model ?? ''
|
||||
const osNormalized = rawOs.toLowerCase() === 'windows' ? 'windows' : 'linux'
|
||||
|
||||
return {
|
||||
name: template.name,
|
||||
defaultDiskSizeGb: size || (osNormalized === 'windows' ? 128 : 64),
|
||||
os: osNormalized,
|
||||
description: template.description,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchTemplates() {
|
||||
const res = await get('/terraform/templates') as TemplatesResponse
|
||||
const list = Array.isArray(res.templates) ? res.templates : []
|
||||
return list.map(normalizeTemplate)
|
||||
}
|
||||
136
src/features/formations/components/Fields.tsx
Normal file
136
src/features/formations/components/Fields.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import type { ChangeEvent, ReactNode } from 'react'
|
||||
|
||||
type FieldWrapperProps = {
|
||||
label: string
|
||||
children: ReactNode
|
||||
helper?: string
|
||||
containerClassName?: string
|
||||
}
|
||||
|
||||
function FieldWrapper({ label, children, helper, containerClassName }: FieldWrapperProps) {
|
||||
return (
|
||||
<label className={`flex flex-col gap-[2px] text-left text-xs ${containerClassName ?? ''}`}>
|
||||
<span className="text-white/70 text-[11px] uppercase tracking-[0.2em]">{label}</span>
|
||||
{children}
|
||||
{helper && <span className="text-[10px] text-white/40">{helper}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
label: string
|
||||
helper?: string
|
||||
value: string | number
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
containerClassName?: string
|
||||
}
|
||||
|
||||
export function TextField({ label, helper, value, onChange, placeholder, required, containerClassName }: FieldProps) {
|
||||
return (
|
||||
<FieldWrapper label={label} helper={helper} containerClassName={containerClassName}>
|
||||
<input
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
type NumberFieldProps = Omit<FieldProps, 'helper'> & { min?: number; max?: number }
|
||||
|
||||
export function NumberField({ label, value, onChange, placeholder, required, min, max, containerClassName }: NumberFieldProps) {
|
||||
return (
|
||||
<FieldWrapper label={label} containerClassName={containerClassName}>
|
||||
<input
|
||||
type="number"
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
min={min}
|
||||
max={max}
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectFieldProps = {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (event: ChangeEvent<HTMLSelectElement>) => void
|
||||
options: string[]
|
||||
}
|
||||
|
||||
export function SelectField({ label, value, onChange, options }: SelectFieldProps) {
|
||||
return (
|
||||
<FieldWrapper label={label}>
|
||||
<select
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
>
|
||||
{options.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</FieldWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
type DisabledFieldProps = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function DisabledField({ label, value }: DisabledFieldProps) {
|
||||
return (
|
||||
<FieldWrapper label={label}>
|
||||
<input
|
||||
className="rounded-lg border border-white/10 bg-white/10 px-3 py-2 text-sm text-white/60"
|
||||
value={value}
|
||||
disabled
|
||||
/>
|
||||
</FieldWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
type RadioOption = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type RadioGroupProps = {
|
||||
label: string
|
||||
name: string
|
||||
value: string
|
||||
options: RadioOption[]
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function RadioGroup({ label, name, value, options, onChange }: RadioGroupProps) {
|
||||
return (
|
||||
<FieldWrapper label={label}>
|
||||
<div className="flex items-center gap-4 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm">
|
||||
{options.map(option => (
|
||||
<label key={option.value} className="flex items-center gap-2 text-white/80 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={value === option.value}
|
||||
onChange={() => onChange(option.value)}
|
||||
className="text-accent focus:ring-accent"
|
||||
/>
|
||||
<span className="capitalize">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</FieldWrapper>
|
||||
)
|
||||
}
|
||||
21
src/features/formations/components/FormCard.tsx
Normal file
21
src/features/formations/components/FormCard.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
type FormCardProps = {
|
||||
title: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function FormCard({ title, className = '', children }: FormCardProps) {
|
||||
return (
|
||||
<section
|
||||
className={`self-start rounded-2xl border border-white/10 bg-white/5 px-3 py-2 backdrop-blur-sm ${className}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-white">{title}</h2>
|
||||
</div>
|
||||
<div className="mt-2">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
25
src/features/formations/components/InfoBox.tsx
Normal file
25
src/features/formations/components/InfoBox.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
type InfoBoxProps = {
|
||||
label: string
|
||||
values: string[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function InfoBox({ label, values, error }: InfoBoxProps) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl border border-white/10 bg-white/5 p-4 text-sm text-white/80">
|
||||
<div className="font-medium text-white">{label}</div>
|
||||
{values.length === 0 ? (
|
||||
<p className="text-white/50 text-sm">Aucune donnée.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{values.slice(0, 6).map((value, index) => (
|
||||
<span key={`${value}-${index}`} className="rounded-md bg-white/10 px-2 py-1 text-xs">{value}</span>
|
||||
))}
|
||||
{values.length > 6 && <span className="text-xs text-white/50">+{values.length - 6} autres</span>}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-xs text-red-300">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
97
src/features/formations/components/PreviewPanel.tsx
Normal file
97
src/features/formations/components/PreviewPanel.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { FormCard } from './FormCard'
|
||||
|
||||
type PreviewPanelProps = {
|
||||
formationName: string
|
||||
userNames: string[]
|
||||
vmNames: string[]
|
||||
ipAddresses: string[]
|
||||
userIds: number[]
|
||||
diskSize: string
|
||||
error?: string
|
||||
loading: boolean
|
||||
onCancel: () => void
|
||||
submitDisabled: boolean
|
||||
}
|
||||
|
||||
export function PreviewPanel({
|
||||
formationName,
|
||||
userNames,
|
||||
vmNames,
|
||||
ipAddresses,
|
||||
userIds,
|
||||
diskSize,
|
||||
error,
|
||||
loading,
|
||||
onCancel,
|
||||
submitDisabled,
|
||||
}: PreviewPanelProps) {
|
||||
return (
|
||||
<aside className="space-y-4">
|
||||
<FormCard title="Résumé">
|
||||
<dl className="space-y-2 text-sm text-white/70">
|
||||
<InfoRow label="Nom" value={formationName || '—'} />
|
||||
<InfoRow label="Utilisateurs" value={String(userNames.length)} />
|
||||
<InfoRow label="VMs" value={String(vmNames.length)} />
|
||||
<InfoRow
|
||||
label="Plage IP"
|
||||
value={ipAddresses.length > 0 ? `${ipAddresses[0]} → ${ipAddresses[ipAddresses.length - 1]}` : '—'}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Identifiants"
|
||||
value={
|
||||
userIds.length > 0
|
||||
? `${userIds[0]} → ${userIds[userIds.length - 1]}`
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<InfoRow label="Disque" value={diskSize} />
|
||||
</dl>
|
||||
</FormCard>
|
||||
|
||||
<FormCard title="Aperçu PDF">
|
||||
<p className="text-xs text-white/60">Formation : <span className="text-white">{formationName || '—'}</span></p>
|
||||
<div className="rounded-lg border border-white/10 bg-black/20 p-3 text-xs text-white/70 space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-semibold">Utilisateur</span>
|
||||
<span className="font-semibold">Mot de passe *</span>
|
||||
</div>
|
||||
{userNames.slice(0, 5).map(user => (
|
||||
<div key={user} className="flex justify-between text-white/60">
|
||||
<span>{user}</span>
|
||||
<span>••••••••</span>
|
||||
</div>
|
||||
))}
|
||||
{userNames.length > 5 && <div className="text-right text-white/40">+{userNames.length - 5} lignes</div>}
|
||||
<p className="text-[11px] text-white/40">* Les mots de passe seront générés automatiquement lors de la création.</p>
|
||||
</div>
|
||||
</FormCard>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{error && <p className="text-sm text-red-300">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/10 px-4 py-2 text-sm font-medium text-white/70 hover:border-accent"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary px-6 disabled:opacity-60"
|
||||
disabled={loading || submitDisabled}
|
||||
>
|
||||
{loading ? 'Création…' : 'Créer la formation'}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-white/60">{label}</dt>
|
||||
<dd className="text-white">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
src/features/formations/constants.ts
Normal file
27
src/features/formations/constants.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { TemplateInfo } from './api'
|
||||
|
||||
export const FALLBACK_TEMPLATE_INFOS: TemplateInfo[] = [
|
||||
{ name: 'debian-tmp', defaultDiskSizeGb: 80, os: 'linux' },
|
||||
{ name: 'ubuntu-22-template', defaultDiskSizeGb: 64, os: 'linux' },
|
||||
{ name: 'windows-11-lab', defaultDiskSizeGb: 128, os: 'windows' },
|
||||
]
|
||||
|
||||
export const FALLBACK_TEMPLATES = FALLBACK_TEMPLATE_INFOS.map(t => t.name)
|
||||
export const MODELS = ['linux', 'windows']
|
||||
|
||||
export const INITIAL_FORM = {
|
||||
name: '',
|
||||
participants: 10,
|
||||
referent: '',
|
||||
guacamoleGroup: '',
|
||||
guacamoleUserPrefix: 'user',
|
||||
vmPrefix: 'VM-',
|
||||
ipStart: '192.168.143.10',
|
||||
template: FALLBACK_TEMPLATES[0],
|
||||
cpuCores: 4,
|
||||
ramGb: 16,
|
||||
userIdStart: 1,
|
||||
diskSizeGb: FALLBACK_TEMPLATE_INFOS[0].defaultDiskSizeGb,
|
||||
model: MODELS[0],
|
||||
networkMode: 'static' as 'static' | 'dhcp',
|
||||
}
|
||||
136
src/features/formations/hooks/useFormationForm.ts
Normal file
136
src/features/formations/hooks/useFormationForm.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { useEffect, useMemo, useState, type ChangeEvent } from 'react'
|
||||
import { fetchTemplates, type TemplateInfo } from '../api'
|
||||
import { computeIpPool } from '../utils/network'
|
||||
import { FALLBACK_TEMPLATE_INFOS, INITIAL_FORM, MODELS } from '../constants'
|
||||
|
||||
export function useFormationForm() {
|
||||
const [form, setForm] = useState(INITIAL_FORM)
|
||||
const [templateInfos, setTemplateInfos] = useState<TemplateInfo[]>(FALLBACK_TEMPLATE_INFOS)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const templateNames = useMemo(() => templateInfos.map(t => t.name), [templateInfos])
|
||||
const currentTemplateInfo = useMemo<TemplateInfo | null>(
|
||||
() => templateInfos.find(t => t.name === form.template) ?? null,
|
||||
[templateInfos, form.template],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates()
|
||||
.then((fetched: TemplateInfo[]) => {
|
||||
if (Array.isArray(fetched) && fetched.length > 0) {
|
||||
setTemplateInfos(fetched)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Impossible de charger les templates', err)
|
||||
setTemplateInfos(FALLBACK_TEMPLATE_INFOS)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const participants = useMemo(
|
||||
() => Math.max(1, Math.min(200, Number(form.participants) || 1)),
|
||||
[form.participants],
|
||||
)
|
||||
|
||||
const userIdStart = useMemo(() => {
|
||||
const raw = Number(form.userIdStart)
|
||||
if (Number.isNaN(raw)) return 1
|
||||
return Math.max(1, Math.floor(raw))
|
||||
}, [form.userIdStart])
|
||||
|
||||
const ipPool = useMemo(() => computeIpPool(form.ipStart, participants), [form.ipStart, participants])
|
||||
|
||||
const userNames = useMemo(
|
||||
() => Array.from({ length: participants }, (_, index) => `${form.guacamoleUserPrefix}${index + 1}`),
|
||||
[form.guacamoleUserPrefix, participants],
|
||||
)
|
||||
|
||||
const vmNames = useMemo(
|
||||
() => Array.from({ length: participants }, (_, index) => `${form.vmPrefix}${index + 1}`),
|
||||
[form.vmPrefix, participants],
|
||||
)
|
||||
|
||||
const userIds = useMemo(
|
||||
() => Array.from({ length: participants }, (_, index) => userIdStart + index),
|
||||
[participants, userIdStart],
|
||||
)
|
||||
|
||||
const diskSizeNumber = useMemo(
|
||||
() => Math.max(1, Number(form.diskSizeGb) || 1),
|
||||
[form.diskSizeGb],
|
||||
)
|
||||
|
||||
const diskSizeDisplay = `${diskSizeNumber}G`
|
||||
const minimumDiskSize = currentTemplateInfo?.defaultDiskSizeGb ?? null
|
||||
const diskTooSmall = minimumDiskSize !== null && diskSizeNumber < minimumDiskSize
|
||||
|
||||
useEffect(() => {
|
||||
if (templateNames.length === 0) return
|
||||
if (!templateNames.includes(form.template)) {
|
||||
setForm(prev => ({ ...prev, template: templateNames[0] }))
|
||||
}
|
||||
}, [form.template, templateNames])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentTemplateInfo) return
|
||||
setForm(prev => {
|
||||
if (prev.template !== currentTemplateInfo.name) return prev
|
||||
if (prev.diskSizeGb >= currentTemplateInfo.defaultDiskSizeGb) return prev
|
||||
return { ...prev, diskSizeGb: currentTemplateInfo.defaultDiskSizeGb }
|
||||
})
|
||||
}, [currentTemplateInfo])
|
||||
|
||||
useEffect(() => {
|
||||
if (!diskTooSmall && error && error.toLowerCase().includes('disque')) {
|
||||
setError('')
|
||||
}
|
||||
}, [diskTooSmall, error])
|
||||
|
||||
const handleInputChange = (field: keyof typeof INITIAL_FORM) => (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.type === 'number' ? Number(event.target.value) : event.target.value
|
||||
setForm(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSelectChange = (field: keyof typeof INITIAL_FORM) => (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = event.target.value
|
||||
setForm(prev => {
|
||||
if (field === 'template') {
|
||||
const info = templateInfos.find(t => t.name === value)
|
||||
return {
|
||||
...prev,
|
||||
[field]: value,
|
||||
diskSizeGb: info ? Math.max(prev.diskSizeGb, info.defaultDiskSizeGb) : prev.diskSizeGb,
|
||||
model: info && info.os && MODELS.includes(info.os.toLowerCase())
|
||||
? info.os.toLowerCase()
|
||||
: prev.model,
|
||||
}
|
||||
}
|
||||
return { ...prev, [field]: value }
|
||||
})
|
||||
}
|
||||
|
||||
const setModel = (model: string) => setForm(prev => ({ ...prev, model }))
|
||||
const setNetworkMode = (mode: string) => setForm(prev => ({ ...prev, networkMode: mode as 'static' | 'dhcp' }))
|
||||
|
||||
return {
|
||||
form,
|
||||
setForm,
|
||||
error,
|
||||
setError,
|
||||
templateNames,
|
||||
participants,
|
||||
userIdStart,
|
||||
ipPool,
|
||||
userNames,
|
||||
vmNames,
|
||||
userIds,
|
||||
diskSizeNumber,
|
||||
diskSizeDisplay,
|
||||
minimumDiskSize,
|
||||
diskTooSmall,
|
||||
handleInputChange,
|
||||
handleSelectChange,
|
||||
setModel,
|
||||
setNetworkMode,
|
||||
}
|
||||
}
|
||||
57
src/features/formations/hooks/useFormations.ts
Normal file
57
src/features/formations/hooks/useFormations.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { deleteFormation, fetchFormations, type TerraformFormationRecord } from '@/features/formations/api'
|
||||
import { useToast } from '@/context/ToastContext'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
export function useFormations() {
|
||||
const toast = useToast()
|
||||
const [formations, setFormations] = useState<TerraformFormationRecord[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
|
||||
const loadFormations = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await fetchFormations()
|
||||
setFormations(data)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Impossible de charger les formations'
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
useEffect(() => {
|
||||
void loadFormations()
|
||||
}, [loadFormations])
|
||||
|
||||
const handleDelete = useCallback(async (formationId: string) => {
|
||||
const confirmed = window.confirm(`Supprimer la formation ${formationId} ?`)
|
||||
if (!confirmed) return
|
||||
|
||||
setDeletingId(formationId)
|
||||
try {
|
||||
await deleteFormation(formationId)
|
||||
setFormations(prev => prev.filter(item => item.formationId !== formationId))
|
||||
toast.success('Formation supprimée')
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Suppression impossible'
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}, [toast])
|
||||
|
||||
return {
|
||||
formations,
|
||||
loading,
|
||||
error,
|
||||
deletingId,
|
||||
loadFormations,
|
||||
handleDelete
|
||||
}
|
||||
}
|
||||
72
src/features/formations/utils/helpers.ts
Normal file
72
src/features/formations/utils/helpers.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import type { TerraformVm } from '../api'
|
||||
|
||||
export const NETWORK_GATEWAY = '192.168.143.1'
|
||||
export const NETWORK_BRIDGE = 'GUACALAN'
|
||||
export const NETWORK_MODEL = 'e1000'
|
||||
export const NETWORK_MASK = '/24'
|
||||
|
||||
export type BuildTerraformVmOptions = {
|
||||
vmNames: string[]
|
||||
userIds: number[]
|
||||
ipAddresses: string[]
|
||||
template: string
|
||||
cores: number
|
||||
ramGb: number
|
||||
diskSizeGb: number
|
||||
credentials: Array<{ username: string; password: string }>
|
||||
networkMode: 'static' | 'dhcp'
|
||||
}
|
||||
|
||||
export function formatIpWithMask(ip: string) {
|
||||
if (ip === 'dhcp') return 'dhcp'
|
||||
return ip.includes('/') ? ip : `${ip}${NETWORK_MASK}`
|
||||
}
|
||||
|
||||
export function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)+/g, '')
|
||||
}
|
||||
|
||||
export function buildFormationId(name: string) {
|
||||
return slugify(name) || 'formation'
|
||||
}
|
||||
|
||||
export function buildTerraformVms({ vmNames, userIds, ipAddresses, template, cores, ramGb, diskSizeGb, credentials, networkMode }: BuildTerraformVmOptions): TerraformVm[] {
|
||||
const memory = Math.max(1, Math.floor(ramGb)) * 1024
|
||||
const disk = Math.max(1, Math.floor(diskSizeGb))
|
||||
return vmNames.map((name, index) => {
|
||||
const vmid = userIds[index]
|
||||
const ip = ipAddresses[index]
|
||||
const cred = credentials[index]
|
||||
if (vmid === undefined || ip === undefined) {
|
||||
throw new Error('Impossible de construire les VMs Terraform (données manquantes).')
|
||||
}
|
||||
|
||||
const vmIp = networkMode === 'dhcp' ? 'dhcp' : formatIpWithMask(ip)
|
||||
|
||||
return {
|
||||
name,
|
||||
vmid,
|
||||
cores: Math.max(1, Math.floor(cores)),
|
||||
memory,
|
||||
disk_size: disk,
|
||||
diskSizeGb: disk,
|
||||
diskSize: disk,
|
||||
template,
|
||||
ip: vmIp,
|
||||
networkInterfaces: [
|
||||
{
|
||||
ip: vmIp,
|
||||
gateway: NETWORK_GATEWAY,
|
||||
bridge: NETWORK_BRIDGE,
|
||||
model: NETWORK_MODEL,
|
||||
},
|
||||
],
|
||||
rdpUsername: cred?.username,
|
||||
rdpPassword: cred?.password,
|
||||
}
|
||||
})
|
||||
}
|
||||
19
src/features/formations/utils/network.ts
Normal file
19
src/features/formations/utils/network.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export function computeIpPool(start: string, count: number) {
|
||||
const base = '192.168.143.'
|
||||
const parts = start.split('.')
|
||||
if (parts.length !== 4) {
|
||||
return { valid: false, ips: [] as string[] }
|
||||
}
|
||||
const last = Number(parts[3])
|
||||
if (parts.slice(0, 3).join('.') !== '192.168.143' || Number.isNaN(last) || last < 1) {
|
||||
return { valid: false, ips: [] as string[] }
|
||||
}
|
||||
const ips: string[] = []
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const current = last + i
|
||||
if (current > 254) return { valid: false, ips }
|
||||
ips.push(`${base}${current}`)
|
||||
}
|
||||
return { valid: ips.length === count, ips }
|
||||
}
|
||||
|
||||
28
src/features/formations/utils/password.ts
Normal file
28
src/features/formations/utils/password.ts
Normal file
@ -0,0 +1,28 @@
|
||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%'
|
||||
|
||||
export function generateStrongPassword(length = 12) {
|
||||
let password = ''
|
||||
const randomValues = new Uint32Array(length)
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
|
||||
crypto.getRandomValues(randomValues)
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
password += alphabet[randomValues[i] % alphabet.length]
|
||||
}
|
||||
return ensureComplexity(password)
|
||||
}
|
||||
|
||||
for (let i = 0; i < length; i += 1) {
|
||||
password += alphabet[Math.floor(Math.random() * alphabet.length)]
|
||||
}
|
||||
return ensureComplexity(password)
|
||||
}
|
||||
|
||||
function ensureComplexity(pass: string) {
|
||||
const hasUpper = /[A-Z]/.test(pass)
|
||||
const hasLower = /[a-z]/.test(pass)
|
||||
const hasDigit = /\d/.test(pass)
|
||||
const hasSymbol = /[!@#$%]/.test(pass)
|
||||
if (hasUpper && hasLower && hasDigit && hasSymbol) return pass
|
||||
return pass.slice(0, -4) + 'Aa1!'
|
||||
}
|
||||
|
||||
60
src/features/formations/utils/pdf.ts
Normal file
60
src/features/formations/utils/pdf.ts
Normal file
@ -0,0 +1,60 @@
|
||||
type CredentialRow = {
|
||||
id?: number | string
|
||||
username: string
|
||||
password: string
|
||||
ip: string
|
||||
vm: string
|
||||
}
|
||||
|
||||
type PdfOptions = {
|
||||
formationName: string
|
||||
rows: CredentialRow[]
|
||||
}
|
||||
|
||||
export async function downloadCredentialsPdf({ formationName, rows }: PdfOptions) {
|
||||
const [jsPDF, autoTable] = await Promise.all([
|
||||
import('jspdf').then(m => m.default),
|
||||
import('jspdf-autotable').then(m => m.default),
|
||||
])
|
||||
|
||||
const doc = new jsPDF({ orientation: 'portrait', unit: 'pt', format: 'a4' })
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(18)
|
||||
doc.text(`Formation: ${formationName}`, 40, 60)
|
||||
|
||||
const includeId = rows.some(row => row.id !== undefined)
|
||||
|
||||
autoTable(doc, {
|
||||
startY: 90,
|
||||
head: [
|
||||
includeId
|
||||
? ['ID', 'Utilisateur', 'Mot de passe', 'VM', 'Adresse IP']
|
||||
: ['Utilisateur', 'Mot de passe', 'VM', 'Adresse IP'],
|
||||
],
|
||||
body: rows.map(row =>
|
||||
includeId
|
||||
? [String(row.id ?? ''), row.username, row.password, row.vm, row.ip]
|
||||
: [row.username, row.password, row.vm, row.ip],
|
||||
),
|
||||
theme: 'grid',
|
||||
styles: {
|
||||
font: 'helvetica',
|
||||
fontSize: 11,
|
||||
cellPadding: 8,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [91, 140, 255],
|
||||
textColor: 255,
|
||||
},
|
||||
})
|
||||
|
||||
doc.save(`credentials-${slugify(formationName)}.pdf`)
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)+/g, '') || 'formation'
|
||||
}
|
||||
27
src/index.css
Normal file
27
src/index.css
Normal file
@ -0,0 +1,27 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--bg: #0b1020;
|
||||
--fg: #e8eefc;
|
||||
--accent: #5b8cff;
|
||||
}
|
||||
html, body, #root { height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply inline-flex items-center justify-center rounded-lg bg-accent px-4 py-2 text-white hover:brightness-110 transition;
|
||||
}
|
||||
.container-narrow {
|
||||
@apply max-w-md mx-auto px-4;
|
||||
}
|
||||
}
|
||||
|
||||
4
src/lib/env.ts
Normal file
4
src/lib/env.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export function getApiBaseUrl() {
|
||||
return '/api'
|
||||
}
|
||||
|
||||
60
src/lib/http.ts
Normal file
60
src/lib/http.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { getApiBaseUrl } from './env'
|
||||
|
||||
type Options = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
body?: unknown
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export async function http(path: string, opts: Options = {}) {
|
||||
const controller = new AbortController()
|
||||
const id = setTimeout(() => controller.abort(), 15000)
|
||||
const base = getApiBaseUrl()
|
||||
const url = base + path
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: opts.method ?? 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(opts.headers ?? {}),
|
||||
},
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
signal: opts.signal ?? controller.signal,
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
const data = safeJson(text)
|
||||
if (!res.ok) {
|
||||
const message = (data && (data.message || data.error)) || res.statusText
|
||||
throw new Error(message)
|
||||
}
|
||||
return data
|
||||
} finally {
|
||||
clearTimeout(id)
|
||||
}
|
||||
}
|
||||
|
||||
function safeJson(text: string) {
|
||||
try {
|
||||
return text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
return { raw: text }
|
||||
}
|
||||
}
|
||||
|
||||
export const get = (path: string, opts: Omit<Options, 'method' | 'body'> = {}) =>
|
||||
http(path, { ...opts, method: 'GET' })
|
||||
|
||||
export const post = (path: string, body?: unknown, opts: Omit<Options, 'method' | 'body'> = {}) =>
|
||||
http(path, { ...opts, method: 'POST', body })
|
||||
|
||||
export const put = (path: string, body?: unknown, opts: Omit<Options, 'method' | 'body'> = {}) =>
|
||||
http(path, { ...opts, method: 'PUT', body })
|
||||
|
||||
export const patch = (path: string, body?: unknown, opts: Omit<Options, 'method' | 'body'> = {}) =>
|
||||
http(path, { ...opts, method: 'PATCH', body })
|
||||
|
||||
export const del = (path: string, opts: Omit<Options, 'method' | 'body'> = {}) =>
|
||||
http(path, { ...opts, method: 'DELETE' })
|
||||
11
src/main.tsx
11
src/main.tsx
@ -1,11 +1,16 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
import './index.css'
|
||||
import { AuthProvider } from './context/AuthContext'
|
||||
import { ToastProvider } from './context/ToastContext'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
|
||||
74
src/pages/AdminDashboard.tsx
Normal file
74
src/pages/AdminDashboard.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { AdminUserCreateForm } from '@/features/admin/components/AdminUserCreateForm'
|
||||
import { AdminUsersTable } from '@/features/admin/components/AdminUsersTable'
|
||||
import { useAdminUsers } from '@/features/admin/hooks/useAdminUsers'
|
||||
import { TemplatesTable } from '@/features/admin/components/TemplatesTable'
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const { user } = useAuth()
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
creating,
|
||||
savingId,
|
||||
deletingId,
|
||||
createUser,
|
||||
updateUser,
|
||||
toggleAdmin,
|
||||
deleteUser,
|
||||
} = useAdminUsers()
|
||||
|
||||
return (
|
||||
<div className="w-full py-12">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-10 px-4 sm:px-8 lg:px-12">
|
||||
<header className="space-y-3 text-center">
|
||||
<p className="text-sm uppercase tracking-[0.35em] text-white/50">Administration</p>
|
||||
<h1 className="text-3xl md:text-4xl font-semibold tracking-tight">Tableau de bord administrateur</h1>
|
||||
<p className="text-white/70">
|
||||
Gestion centralisée des utilisateurs de la plateforme. Connecté en tant que{' '}
|
||||
<span className="font-semibold text-white">{user?.name}</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-400/40 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
|
||||
<h2 className="text-xl font-semibold">Créer un nouvel utilisateur</h2>
|
||||
<p className="mt-1 text-sm text-white/70">
|
||||
Les nouveaux comptes reçoivent un mot de passe provisoire. Vous pouvez ensuite leur attribuer le rôle
|
||||
administrateur au besoin.
|
||||
</p>
|
||||
<AdminUserCreateForm
|
||||
busy={creating}
|
||||
onSubmit={async payload => {
|
||||
return createUser(payload)
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AdminUsersTable
|
||||
users={users}
|
||||
loading={loading}
|
||||
savingId={savingId}
|
||||
deletingId={deletingId}
|
||||
onUpdate={async (id, payload) => {
|
||||
return updateUser(id, payload)
|
||||
}}
|
||||
onToggleAdmin={async candidate => {
|
||||
return toggleAdmin(candidate)
|
||||
}}
|
||||
onDelete={async candidate => {
|
||||
return deleteUser(candidate)
|
||||
}}
|
||||
/>
|
||||
|
||||
<TemplatesTable />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
246
src/pages/CreateFormation.tsx
Normal file
246
src/pages/CreateFormation.tsx
Normal file
@ -0,0 +1,246 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { applyTerraform, type TerraformPayload } from '@/features/formations/api'
|
||||
import { FormCard } from '@/features/formations/components/FormCard'
|
||||
import { DisabledField, NumberField, RadioGroup, SelectField, TextField } from '@/features/formations/components/Fields'
|
||||
import { InfoBox } from '@/features/formations/components/InfoBox'
|
||||
import { PreviewPanel } from '@/features/formations/components/PreviewPanel'
|
||||
import { generateStrongPassword } from '@/features/formations/utils/password'
|
||||
import { downloadCredentialsPdf } from '@/features/formations/utils/pdf'
|
||||
import { buildFormationId, buildTerraformVms } from '@/features/formations/utils/helpers'
|
||||
import { useToast } from '@/context/ToastContext'
|
||||
import { useFormationForm } from '@/features/formations/hooks/useFormationForm'
|
||||
import { MODELS } from '@/features/formations/constants'
|
||||
|
||||
export default function CreateFormation() {
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const {
|
||||
form,
|
||||
error,
|
||||
setError,
|
||||
templateNames,
|
||||
participants,
|
||||
userIdStart,
|
||||
ipPool,
|
||||
userNames,
|
||||
vmNames,
|
||||
userIds,
|
||||
diskSizeNumber,
|
||||
diskSizeDisplay,
|
||||
minimumDiskSize,
|
||||
diskTooSmall,
|
||||
handleInputChange,
|
||||
handleSelectChange,
|
||||
setModel,
|
||||
setNetworkMode,
|
||||
} = useFormationForm()
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!form.name.trim()) return setError('Le nom de la formation est requis')
|
||||
if (!form.referent.trim()) return setError('Le référent est requis')
|
||||
if (!form.guacamoleGroup.trim()) return setError('Le groupe Guacamole est requis')
|
||||
if (!ipPool.valid) return setError("La plage IP doit rester dans 192.168.143.0/24 et disposer d'assez d'adresses")
|
||||
if (minimumDiskSize !== null && diskSizeNumber < minimumDiskSize) {
|
||||
return setError(`Le disque doit être au moins de ${minimumDiskSize} Go pour le template ${form.template}`)
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const credentials = userNames.map((username, index) => {
|
||||
const ip = ipPool.ips[index]
|
||||
const vmName = vmNames[index]
|
||||
const id = userIds[index]
|
||||
|
||||
if (!ip || !vmName) throw new Error('Incohérence entre les utilisateurs, les VMs ou les adresses IP générées')
|
||||
if (!id) throw new Error('Incohérence entre les identifiants générés et les participants')
|
||||
|
||||
return {
|
||||
id,
|
||||
username,
|
||||
password: generateStrongPassword(),
|
||||
displayName: `Utilisateur ${index + 1}`,
|
||||
vm: {
|
||||
name: vmName,
|
||||
ip,
|
||||
cpuCores: Number(form.cpuCores),
|
||||
ramGb: Number(form.ramGb),
|
||||
template: form.template,
|
||||
diskSize: diskSizeNumber,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const terraformPayload: TerraformPayload = {
|
||||
formationId: buildFormationId(form.name),
|
||||
model: form.model,
|
||||
vms: buildTerraformVms({
|
||||
vmNames,
|
||||
userIds,
|
||||
ipAddresses: ipPool.ips,
|
||||
template: form.template,
|
||||
cores: Number(form.cpuCores),
|
||||
ramGb: Number(form.ramGb),
|
||||
diskSizeGb: diskSizeNumber,
|
||||
credentials: credentials.map(({ username, password }) => ({ username, password })),
|
||||
networkMode: form.networkMode,
|
||||
}),
|
||||
guacamole: {
|
||||
groupName: form.guacamoleGroup.trim(),
|
||||
users: credentials.map(entry => ({
|
||||
username: entry.username,
|
||||
password: entry.password,
|
||||
displayName: entry.displayName,
|
||||
ip: entry.vm.ip === 'dhcp' ? ipPool.ips[credentials.indexOf(entry)] : entry.vm.ip,
|
||||
})),
|
||||
},
|
||||
}
|
||||
|
||||
const response = await applyTerraform(terraformPayload)
|
||||
|
||||
void downloadCredentialsPdf({
|
||||
formationName: form.name,
|
||||
rows: credentials.map(item => ({
|
||||
id: item.id,
|
||||
username: item.username,
|
||||
password: item.password,
|
||||
vm: item.vm.name,
|
||||
ip: item.vm.ip,
|
||||
})),
|
||||
}).catch(() => toast.error('Impossible de générer le PDF Guacamole'))
|
||||
|
||||
const friendlyName = form.name.trim() || response.formationId
|
||||
const detail = response.remotePath ? ` (dossier: ${response.remotePath})` : ''
|
||||
const baseMessage = response.status === 'queued'
|
||||
? `Déploiement en file d'attente pour ${friendlyName}`
|
||||
: `Déploiement ${response.status} pour ${friendlyName}`
|
||||
|
||||
toast.info(`${baseMessage}${detail}`)
|
||||
navigate('/dashboard', { replace: true })
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || 'Erreur lors de la création de la formation'
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full py-6">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-4 sm:px-8">
|
||||
<header className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-white/50">Nouvelle formation</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Configuration</h1>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs text-white/60">
|
||||
PDF généré automatiquement après validation
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="grid items-start gap-3 xl:grid-cols-[2.1fr_1fr]">
|
||||
<div className="space-y-3">
|
||||
<FormCard title="Formation">
|
||||
<div className="grid items-center gap-2 md:grid-cols-3 xl:grid-cols-[1.4fr_1.4fr_1fr_1fr]">
|
||||
<TextField label="Nom" value={form.name} onChange={handleInputChange('name')} placeholder="Promo DevOps Juin" required />
|
||||
<TextField label="Référent" value={form.referent} onChange={handleInputChange('referent')} placeholder="Nom du formateur" required />
|
||||
<NumberField
|
||||
label="Participants"
|
||||
value={participants}
|
||||
min={1}
|
||||
max={200}
|
||||
onChange={handleInputChange('participants')}
|
||||
containerClassName="md:max-w-[9rem] md:justify-self-end"
|
||||
/>
|
||||
<NumberField
|
||||
label="Identifiant de départ"
|
||||
value={userIdStart}
|
||||
min={1}
|
||||
max={999999}
|
||||
onChange={handleInputChange('userIdStart')}
|
||||
containerClassName="md:max-w-[9rem] md:justify-self-end"
|
||||
/>
|
||||
</div>
|
||||
<InfoBox label="Identifiants" values={userIds.map(id => String(id))} />
|
||||
</FormCard>
|
||||
|
||||
<FormCard title="Guacamole">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<TextField label="Groupe" value={form.guacamoleGroup} onChange={handleInputChange('guacamoleGroup')} placeholder="formation-devops" required />
|
||||
<TextField label="Préfixe user" value={form.guacamoleUserPrefix} onChange={handleInputChange('guacamoleUserPrefix')} placeholder="user" required />
|
||||
</div>
|
||||
<InfoBox label="Utilisateurs générés" values={userNames} />
|
||||
</FormCard>
|
||||
|
||||
<FormCard title="Machines virtuelles">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<TextField label="Préfixe VM" value={form.vmPrefix} onChange={handleInputChange('vmPrefix')} placeholder="VM-" required />
|
||||
<RadioGroup
|
||||
label="Modèle"
|
||||
name="model"
|
||||
value={form.model}
|
||||
options={MODELS.map(m => ({ label: m, value: m }))}
|
||||
onChange={setModel}
|
||||
/>
|
||||
<SelectField label="Template" value={form.template} onChange={handleSelectChange('template')} options={templateNames} />
|
||||
<NumberField label="CPU (cœurs)" value={form.cpuCores} min={1} max={32} onChange={handleInputChange('cpuCores')} />
|
||||
<NumberField label="RAM (Go)" value={form.ramGb} min={1} max={256} onChange={handleInputChange('ramGb')} />
|
||||
<NumberField label="Disque (Go)" value={form.diskSizeGb} min={1} max={1024} onChange={handleInputChange('diskSizeGb')} />
|
||||
{minimumDiskSize !== null && (
|
||||
<p className={`md:col-span-2 text-xs ${diskTooSmall ? 'text-red-300' : 'text-white/60'}`}>
|
||||
Disque minimum pour ce template : {minimumDiskSize} Go
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<InfoBox label="Nom des VMs" values={vmNames} />
|
||||
</FormCard>
|
||||
|
||||
<FormCard title="Réseau">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<RadioGroup
|
||||
label="Mode"
|
||||
name="networkMode"
|
||||
value={form.networkMode}
|
||||
options={[
|
||||
{ label: 'Statique', value: 'static' },
|
||||
{ label: 'DHCP', value: 'dhcp' }
|
||||
]}
|
||||
onChange={setNetworkMode}
|
||||
/>
|
||||
<TextField label="IP de départ (Guacamole)" value={form.ipStart} onChange={handleInputChange('ipStart')} helper="Reste dans 192.168.143.x" required />
|
||||
{form.networkMode === 'static' && <DisabledField label="Sous-réseau" value="192.168.143.0/24" />}
|
||||
</div>
|
||||
<InfoBox
|
||||
label={form.networkMode === 'static' ? "Plage attribuée (VMs + Guacamole)" : "Plage attribuée (Guacamole uniquement)"}
|
||||
values={ipPool.ips}
|
||||
error={ipPool.valid ? undefined : 'Plage insuffisante'}
|
||||
/>
|
||||
</FormCard>
|
||||
</div>
|
||||
|
||||
<PreviewPanel
|
||||
formationName={form.name}
|
||||
userNames={userNames}
|
||||
vmNames={vmNames}
|
||||
ipAddresses={ipPool.ips}
|
||||
userIds={userIds}
|
||||
diskSize={diskSizeDisplay}
|
||||
error={error || (diskTooSmall && minimumDiskSize ? `Disque minimal requis : ${minimumDiskSize} Go` : '')}
|
||||
loading={loading}
|
||||
onCancel={() => navigate(-1)}
|
||||
submitDisabled={!ipPool.valid || diskTooSmall}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
156
src/pages/Dashboard.tsx
Normal file
156
src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useFormations } from '@/features/formations/hooks/useFormations'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { formations, loading, error, deletingId, loadFormations, handleDelete } = useFormations()
|
||||
|
||||
const activeCount = useMemo(() => formations.length, [formations])
|
||||
|
||||
return (
|
||||
<div className="w-full py-12">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-12 px-4 sm:px-8 lg:px-12">
|
||||
<header className="text-center">
|
||||
<p className="text-sm uppercase tracking-[0.35em] text-white/50">Formations actives</p>
|
||||
<h1 className="mt-4 text-3xl md:text-4xl font-semibold tracking-tight">Espace de gestion AutoDeploy</h1>
|
||||
<p className="mt-3 text-white/70">Visualisez l’état de vos déploiements Terraform et gérez leurs cycles de vie.</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-sm">
|
||||
<div className="mb-6 flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Formations actives</h2>
|
||||
<p className="text-sm text-white/70">Liste des déploiements générés via Terraform.</p>
|
||||
</div>
|
||||
<span className="text-sm text-white/60">
|
||||
{activeCount} formation{activeCount > 1 ? 's' : ''} en cours
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[720px] table-auto border-separate border-spacing-y-2">
|
||||
<thead className="text-left text-sm text-white/60">
|
||||
<tr>
|
||||
<th className="rounded-l-lg bg-white/5 px-4 py-3 font-normal">Formation</th>
|
||||
<th className="bg-white/5 px-4 py-3 font-normal">Chemin distant</th>
|
||||
<th className="bg-white/5 px-4 py-3 font-normal">Statut</th>
|
||||
<th className="bg-white/5 px-4 py-3 font-normal">Créée le</th>
|
||||
<th className="rounded-r-lg bg-white/5 px-4 py-3 font-normal text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-4 text-center text-sm text-white/60">
|
||||
Chargement des formations…
|
||||
</td>
|
||||
</tr>
|
||||
) : formations.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-4 text-center text-sm text-white/60">
|
||||
Aucune formation active pour le moment. Créez-en une via le bouton ci-dessous.
|
||||
</td>
|
||||
</tr>
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-4 text-center text-sm text-red-300">
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
formations.map((f) => (
|
||||
<tr key={f.id} className="rounded-lg bg-white/5 text-sm text-white/80">
|
||||
<td className="rounded-l-lg px-4 py-3 font-medium text-white">{f.formationId}</td>
|
||||
<td className="px-4 py-3 text-white/70">
|
||||
<span className="break-words">{f.remotePath}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={f.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3">{formatDate(f.createdAt)}</td>
|
||||
<td className="rounded-r-lg px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(f.formationId)}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-red-400/40 px-3 py-1.5 text-xs font-medium text-red-300 hover:bg-red-500/10 disabled:opacity-60"
|
||||
disabled={deletingId === f.formationId}
|
||||
>
|
||||
{deletingId === f.formationId ? 'Suppression…' : 'Supprimer'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap justify-end gap-3">
|
||||
<button
|
||||
onClick={() => void loadFormations()}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/10 px-4 py-2 text-sm text-white/70 hover:border-accent disabled:opacity-60"
|
||||
disabled={loading}
|
||||
>
|
||||
Rafraîchir
|
||||
</button>
|
||||
<Link className="btn-primary px-6" to="/formations">
|
||||
Créer une formation
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(input: string) {
|
||||
const date = new Date(input)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return date.toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status?: string }) {
|
||||
const normalized = typeof status === 'string' ? status.toLowerCase() : 'unknown'
|
||||
const config = STATUS_CONFIG[normalized]
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold bg-white/10 text-white/70 border border-white/20">
|
||||
{status || 'Inconnu'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${config.className}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
|
||||
queued: {
|
||||
label: 'En cours',
|
||||
className: 'bg-amber-400/10 text-amber-200 border border-amber-400/40',
|
||||
},
|
||||
processing: {
|
||||
label: 'En cours',
|
||||
className: 'bg-blue-400/10 text-blue-200 border border-blue-400/40',
|
||||
},
|
||||
completed: {
|
||||
label: 'Actif',
|
||||
className: 'bg-emerald-400/10 text-emerald-200 border border-emerald-400/40',
|
||||
},
|
||||
failed: {
|
||||
label: 'Échec',
|
||||
className: 'bg-red-500/10 text-red-300 border border-red-400/40',
|
||||
},
|
||||
unknown: {
|
||||
label: 'En cours',
|
||||
className: 'bg-white/10 text-white/70 border border-white/20',
|
||||
},
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
:root {
|
||||
--bg: #0b1020;
|
||||
--fg: #e8eefc;
|
||||
--accent: #5b8cff;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 1.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover { filter: brightness(1.1); }
|
||||
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
tailwind.config.ts
Normal file
21
tailwind.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{ts,tsx,js,jsx}'
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: '#0b1020',
|
||||
fg: '#e8eefc',
|
||||
accent: '#5b8cff',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
export default config
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
|
||||
@ -5,7 +5,10 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': 'http://192.168.1.37:4447',
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
port: 5173
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user