Compare commits

...

17 Commits
main ... test

54 changed files with 4917 additions and 312 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL=http://127.0.0.1:3000

2
.env.development Normal file
View File

@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://127.0.0.1:3000

7
.env.example Normal file
View 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
View 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

25
DOCKER_README.md Normal file
View File

@ -0,0 +1,25 @@
# Docker Development Setup
To build and run the Docker container locally:
1. **Build the image**:
You need to pass the `VITE_API_BASE_URL` as a build argument because Vite bakes environment variables into the static files at build time.
```bash
docker build \
--build-arg VITE_API_BASE_URL=http://localhost:3000/api \
-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
Since this is a client-side application (React/Vite), environment variables like `VITE_API_BASE_URL` are replaced during the `npm run build` process. They are **not** read from the server's environment at runtime (unlike a Node.js backend).
If you need to change the API URL for different environments (dev, staging, prod) without rebuilding the Docker image, you would need a runtime configuration strategy (e.g., loading a `config.js` file at runtime), but the current setup follows the standard "build-time bake" approach for simplicity and performance.

42
Dockerfile Normal file
View File

@ -0,0 +1,42 @@
# 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
# We use a build argument to pass the API URL at build time if needed,
# or it can be overridden at runtime for the preview/dev server if we were serving that.
# However, for a static build served by Nginx, env vars are baked in at build time.
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
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;"]

133
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,133 @@
// Pipeline Jenkins pour le dépôt Frontend
// Déclenchée lors d'un push sur la branche 'dev'
// Objectif : Construire une image Docker, la déployer sur une VM
pipeline {
// Les options de construction et le déclencheur
options {
// Option pour annuler automatiquement les builds précédentes en cours
disableConcurrentBuilds()
}
// Définition de l'agent (exécuteur) de Jenkins
agent any
// Paramètres qui devront être définis dans Jenkins (gérés comme des secrets)
// Ces identifiants doivent être configurés dans Jenkins.
environment {
// Nom du service Docker (à adapter)
SERVICE_NAME = 'AutoDeploy'
// Registre Docker cible (ex: votre Gitea Registry, Docker Hub, ou un registre privé)
// Remplacez par votre registre. Par exemple : 'registry.votre-domaine.com/frontend'
DOCKER_REGISTRY_URL = 'gitea.firewax.fr/corenthin/test:latest'
// Hôte de déploiement (la VM contenant l'outil d'exécution)
DEPLOY_HOST = '172.75.13.5'
// Identifiant Jenkins pour l'accès SSH à la VM de déploiement (doit exister dans Jenkins)
// REMPLACÉ PAR UN ID NUMÉRIQUE SELON VOTRE DEMANDE
DEPLOY_CREDENTIALS_ID = '1000'
}
triggers {
// Le déclenchement est maintenant géré par le Generic Webhook Trigger configuré dans l'interface du Job.
// On retire pollSCM('') car il n'est plus pertinent.
}
stages {
stage('Checkout Code') {
steps {
// Récupération du code source depuis Gitea
// Nous utilisons 'scm' qui est configuré dans le Job,
// et Jenkins s'occupe de checkout la branche qui a déclenché le build.
checkout scm
}
}
stage('Build Docker Image') {
steps {
script {
// Création d'un tag basé sur le hachage de commit court (pour unicité)
def shortCommit = sh(returnStdout: true, script: 'git rev-parse --short HEAD').trim()
// Tag complet de l'image
env.IMAGE_TAG = "${env.DOCKER_REGISTRY_URL}:${shortCommit}"
// --- NOUVEAU: Injection des variables d'environnement sécurisées ---
// Ceci suppose que vous avez un Credential ID 'frontend-api-url-secret' de type Secret Text
withCredentials([string(credentialsId: 'frontend-api-url-secret', variable: 'API_URL')]) {
// Construction de l'image Docker en passant la variable secrète comme build-arg
echo "Construction de l'image Docker avec le tag : ${env.IMAGE_TAG}"
// L'argument --build-arg est utilisé pour injecter la variable pendant le build
sh "docker build -t ${env.IMAGE_TAG} --build-arg API_URL=${API_URL} ."
// NOTE IMPORTANTE: Le secret ${API_URL} n'est PAS affiché dans la console du build grâce à withCredentials.
}
// -------------------------------------------------------------------
}
}
}
stage('Push Docker Image') {
steps {
// Authentification au registre Docker (nécessite un Credential ID 'docker-registry-credentials')
// Ce Credential ID doit contenir les identifiants pour se connecter au registre.
withCredentials([usernamePassword(credentialsId: 'docker-registry-credentials', passwordVariable: 'DOCKER_PASSWORD', usernameVariable: 'DOCKER_USERNAME')]) {
sh "echo \$DOCKER_PASSWORD | docker login -u \$DOCKER_USERNAME --password-stdin ${env.DOCKER_REGISTRY_URL.substring(0, env.DOCKER_REGISTRY_URL.indexOf('/'))}"
// Push de l'image
sh "docker push ${env.IMAGE_TAG}"
// Nettoyage de l'image locale après le push
sh "docker rmi ${env.IMAGE_TAG}"
}
}
}
stage('Deploy on VM') {
steps {
// Utilisation du plugin SSH Agent (ou SSH Pipeline Steps) pour se connecter à la VM.
// Assurez-vous d'avoir le plugin 'SSH Agent' installé dans Jenkins.
sshagent(credentials: [env.DEPLOY_CREDENTIALS_ID]) {
// Script de déploiement à exécuter sur la VM
def deployScript = """
# 1. Pull de la nouvelle image
docker pull ${env.IMAGE_TAG}
# 2. Arrêt du container existant (si il existe)
if [ \$(docker ps -a -q -f name=${env.SERVICE_NAME}) ]; then
echo "Arrêt du container existant..."
docker stop ${env.SERVICE_NAME}
docker rm ${env.SERVICE_NAME}
fi
# 3. Lancement du nouveau container
echo "Démarrage du nouveau container..."
docker run -d --name ${env.SERVICE_NAME} -p 80:80 ${env.IMAGE_TAG}
# 4. Nettoyage des anciennes images
docker image prune -f --filter "until=24h"
"""
// Exécution du script via SSH sur la VM de déploiement
sh "ssh -o StrictHostKeyChecking=no user@${env.DEPLOY_HOST} \"${deployScript}\""
}
}
}
stage('Cleanup') {
steps {
// Nettoyage de l'espace de travail local sur le runner Jenkins
cleanWs()
}
}
}
// Actions post-build (à adapter)
post {
always {
// Envoi de notifications, archivage des artefacts, etc.
echo "Pipeline Frontend terminée pour le commit: ${env.IMAGE_TAG}"
}
failure {
echo '!!! ÉCHEC de la pipeline Frontend !!!'
}
}
}

View File

@ -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>

26
nginx.conf Normal file
View File

@ -0,0 +1,26 @@
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;
}
# 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

File diff suppressed because it is too large Load Diff

View File

@ -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
View File

@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -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

View 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
View 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>
)
}

View 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 />
}

View 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
View 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
}

View 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
View 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>
}

View 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("Lemail 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="text"
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>
)
}

View 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>
)
}

View 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("Lemail 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>
</>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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,
}
}

View 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
View 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>
}

View 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>
)
}

View 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>
)
}

View 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 }
}

View 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>
)
}

View 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
}

View File

@ -0,0 +1,122 @@
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
}>
}
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)
}

View File

@ -0,0 +1,89 @@
import type { ChangeEvent } from 'react'
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 (
<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>
<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}
/>
{helper && <span className="text-[10px] text-white/40">{helper}</span>}
</label>
)
}
type NumberFieldProps = Omit<FieldProps, 'helper'> & { min?: number; max?: number }
export function NumberField({ label, value, onChange, placeholder, required, min, max, containerClassName }: NumberFieldProps) {
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>
<input
type="text"
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}
/>
</label>
)
}
type SelectFieldProps = {
label: string
value: string
onChange: (event: ChangeEvent<HTMLSelectElement>) => void
options: string[]
}
export function SelectField({ label, value, onChange, options }: SelectFieldProps) {
return (
<label className="flex flex-col gap-[2px] text-left text-xs">
<span className="text-white/70 text-[11px] uppercase tracking-[0.2em]">{label}</span>
<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>
</label>
)
}
type DisabledFieldProps = {
label: string
value: string
}
export function DisabledField({ label, value }: DisabledFieldProps) {
return (
<label className="flex flex-col gap-[2px] text-left text-xs">
<span className="text-white/70 text-[11px] uppercase tracking-[0.2em]">{label}</span>
<input
className="rounded-lg border border-white/10 bg-white/10 px-3 py-2 text-sm text-white/60"
value={value}
disabled
/>
</label>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}
}

View File

@ -0,0 +1,68 @@
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 }>
}
export function formatIpWithMask(ip: string) {
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 }: 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).')
}
return {
name,
vmid,
cores: Math.max(1, Math.floor(cores)),
memory,
disk_size: disk,
diskSizeGb: disk,
diskSize: disk,
template,
ip: formatIpWithMask(ip),
networkInterfaces: [
{
ip: formatIpWithMask(ip),
gateway: NETWORK_GATEWAY,
bridge: NETWORK_BRIDGE,
model: NETWORK_MODEL,
},
],
rdpUsername: cred?.username,
rdpPassword: cred?.password,
}
})
}

View 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 }
}

View 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!'
}

View 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
View 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;
}
}

8
src/lib/env.ts Normal file
View File

@ -0,0 +1,8 @@
export function getApiBaseUrl() {
const url = import.meta.env.VITE_API_BASE_URL as string | undefined
if (!url) {
throw new Error('VITE_API_BASE_URL is not defined')
}
return url.replace(/\/$/, '')
}

60
src/lib/http.ts Normal file
View 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' })

View File

@ -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>,
)

View 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>
)
}

View File

@ -0,0 +1,359 @@
import { useEffect, useMemo, useState } from 'react'
import type { ChangeEvent, FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { applyTerraform, fetchTemplates, type TemplateInfo, type TerraformPayload } from '@/features/formations/api'
import { FormCard } from '@/features/formations/components/FormCard'
import { DisabledField, NumberField, 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 { computeIpPool } from '@/features/formations/utils/network'
import { buildFormationId, buildTerraformVms } from '@/features/formations/utils/helpers'
import { useToast } from '@/context/ToastContext'
const fallbackTemplateInfos: TemplateInfo[] = [
{ name: 'debian-tmp', defaultDiskSizeGb: 80, os: 'linux' },
{ name: 'ubuntu-22-template', defaultDiskSizeGb: 64, os: 'linux' },
{ name: 'windows-11-lab', defaultDiskSizeGb: 128, os: 'windows' },
]
const fallbackTemplates = fallbackTemplateInfos.map(t => t.name)
const models = ['linux', 'windows']
const INITIAL_FORM = {
name: '',
participants: 10,
referent: '',
guacamoleGroup: '',
guacamoleUserPrefix: 'user',
vmPrefix: 'VM-',
ipStart: '192.168.143.10',
template: fallbackTemplates[0],
cpuCores: 4,
ramGb: 16,
userIdStart: 1,
diskSizeGb: fallbackTemplateInfos[0].defaultDiskSizeGb,
model: models[0],
}
export default function CreateFormation() {
const navigate = useNavigate()
const toast = useToast()
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [form, setForm] = useState(INITIAL_FORM)
const [templateInfos, setTemplateInfos] = useState<TemplateInfo[]>(fallbackTemplateInfos)
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(fallbackTemplateInfos)
})
}, [])
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])
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 }
})
}
useEffect(() => {
if (!diskTooSmall && error && error.toLowerCase().includes('disque')) {
setError('')
}
}, [diskTooSmall, error])
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setError('')
if (!form.name.trim()) {
setError('Le nom de la formation est requis')
return
}
if (!form.referent.trim()) {
setError('Le référent est requis')
return
}
if (!form.guacamoleGroup.trim()) {
setError('Le groupe Guacamole est requis')
return
}
if (!ipPool.valid) {
setError("La plage IP doit rester dans 192.168.143.0/24 et disposer d'assez d'adresses")
return
}
if (minimumDiskSize !== null && diskSizeNumber < minimumDiskSize) {
setError(`Le disque doit être au moins de ${minimumDiskSize} Go pour le template ${form.template}`)
return
}
setLoading(true)
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')
}
const password = generateStrongPassword()
const displayName = `Utilisateur ${index + 1}`
return {
id,
username,
password,
displayName,
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 })),
}),
guacamole: {
groupName: form.guacamoleGroup.trim(),
users: credentials.map(entry => ({
username: entry.username,
password: entry.password,
displayName: entry.displayName,
})),
},
}
try {
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) {
setError(err?.message || 'Erreur lors de la création de la formation')
toast.error(err?.message || 'Erreur lors de la création de la formation')
} 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 />
<div className="flex flex-col gap-[2px] text-left text-xs">
<span className="text-white/70 text-[11px] uppercase tracking-[0.2em]">Modèle</span>
<div className="flex items-center gap-4 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm">
{models.map(option => (
<label key={option} className="flex items-center gap-2 text-white/80">
<input
type="radio"
name="model"
value={option}
checked={form.model === option}
onChange={() => setForm(prev => ({ ...prev, model: option }))}
/>
<span className="capitalize">{option}</span>
</label>
))}
</div>
</div>
<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">
<TextField label="IP de départ" value={form.ipStart} onChange={handleInputChange('ipStart')} helper="Reste dans 192.168.143.x" required />
<DisabledField label="Sous-réseau" value="192.168.143.0/24" />
</div>
<InfoBox label="Plage attribuée" 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
View 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 file',
className: 'bg-amber-400/10 text-amber-200 border border-amber-400/40',
},
processing: {
label: 'Déploiement',
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: 'Inconnu',
className: 'bg-white/10 text-white/70 border border-white/20',
},
}

View File

@ -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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

21
tailwind.config.ts Normal file
View 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

View File

@ -1,4 +1,4 @@
{
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,