Compare commits

...

40 Commits
main ... dev

Author SHA1 Message Date
5012e197c3 test 2025-12-12 14:37:59 +01:00
e8a80a2dde Actualiser Jenkinsfile 2025-12-12 11:08:36 +01:00
corenthin-lebreton
09cc1633cc modif badge statut formations 2025-12-02 20:57:38 +01:00
corenthin-lebreton
4cff540ef7 Refacto du code 2025-12-02 20:24:14 +01:00
488d90880c modification type réseau 2025-12-02 17:22:51 +01:00
9757b6bcfa modif route 2025-12-01 16:36:37 +01:00
c0d572cf02 empty commit to push 2025-12-01 15:57:39 +01:00
051e870170 Chagmeent formulaire 2025-12-01 15:49:16 +01:00
69a9558524 Remodif Jenkinsfile 2025-12-01 15:34:04 +01:00
83a0a6e960 modification Jenkinsfile 2025-12-01 15:31:21 +01:00
3839143911 Modif Jenkinsfile pour suppression du container front 2025-12-01 15:28:56 +01:00
95137ce11b modif nginx 2025-12-01 15:23:55 +01:00
3d1945bd73 modif Dockerfile 2025-11-28 12:47:45 +01:00
2de18c9bcb modif Dockerfile 2025-11-28 12:32:12 +01:00
0a72486059 modif URL API 2025-11-28 12:09:33 +01:00
3232c34f85 Test pipeline 2025-11-28 11:54:23 +01:00
9c5aed9459 change docker port 2025-11-27 16:08:42 +01:00
67959fb82a modif user 2025-11-27 16:04:30 +01:00
1f7e893fbc modif tag image docker 2025-11-27 16:01:38 +01:00
9838c97d15 modif variable Jenkinsfile 2025-11-27 15:51:42 +01:00
f3fc859800 correction syntaxe 2025-11-27 15:44:07 +01:00
b06c952ad2 modif Jenkinsfile 2 2025-11-27 15:42:37 +01:00
008451d15e modif Jenkinsfile 2025-11-27 15:39:09 +01:00
eba5817ae5 Modif DevOps Pipeline 2025-11-27 12:45:06 +01:00
2deb3639ec opti frontend 2025-11-26 21:47:39 +01:00
370a3a25bc modif 2025-11-25 23:48:23 +01:00
d30d87aae1 (add): get templates informations from API + asynchrone queued/deploy status for formations 2025-10-13 14:04:21 +02:00
611a4ef94a modif 2025-10-10 16:43:55 +02:00
7b109b3789 modif' 2025-10-10 01:09:40 +02:00
ba8b13f361 (add): premiere version de la feature de création de formations 2025-10-09 15:13:52 +02:00
27fdec6a0a modification du style, sécurité 2025-10-08 16:58:44 +02:00
6fa911d194 (update): modification de la navbar 2025-10-08 16:05:42 +02:00
d414f1a46a (add): admin dashboard 2025-10-08 15:48:53 +02:00
437e57ed58 ajout page de création de formation 2025-10-07 15:41:35 +02:00
7526bd03bf Refacto 2025-10-07 12:24:58 +02:00
0ac648a700 Modification du JSON envoyé à l'API pour le register 2025-10-07 12:16:43 +02:00
75a2f5b89a (update): fix login and register features (add):dashboard template page + creation page template 2025-10-07 00:39:51 +02:00
066b9c92bf (add): connexion à l'API pour register + login 2025-10-06 19:19:02 +02:00
7c7606db38 (add): Tailwindcss + register and login page 2025-10-06 16:57:10 +02:00
005bcfad43 initial project on dev 2025-10-06 15:54:37 +02:00
57 changed files with 4991 additions and 313 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE_URL=http://192.168.1.37:4447

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

20
DOCKER_README.md Normal file
View 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
View 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
View 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 !!!'
}
}
}

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>

37
nginx.conf Normal file
View 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

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="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>
)
}

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

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

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

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

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

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

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

@ -0,0 +1,4 @@
export function getApiBaseUrl() {
return '/api'
}

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

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,

View File

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