Author SHA1 Message Date
Antho d27ff11ea2 fix(historique): période par défaut du 1er janvier à aujourd'hui
La page se limitait aux 5 derniers jours par défaut, alors que
l'historique disponible remonte au 1er janvier. Corrige aussi un bug de
fuseau horaire : les dates par défaut étaient calculées avec
toISOString(), qui bascule en UTC (un 1er janvier saisi depuis Paris,
UTC+1, devenait le 31 décembre de l'année précédente) — remplacé par un
formatage en heure locale.

La page charge maintenant directement cette période au montage, au lieu
d'attendre un clic sur "Afficher" pour afficher autre chose qu'un écran
vide sous les filtres.
2026-08-15 13:28:43 +02:00
Antho 25a31feec4 feat(dashboard): filtres services/catégories restreints aux résultats
Les select de service et catégorie utilisaient getServices()/getCategories(),
soit les référentiels complets — dont certaines entrées (ex. "Business
Intelligence") n'ont aucun monitoring rattaché et menaient sur un dashboard
vide. Bascule vers le nouvel endpoint GET /dashboard/filtres (VUE_CONSO),
avec filtrage en cascade : sélectionner un service restreint la liste des
catégories proposées à celles qui ont réellement des résultats pour ce
service.
2026-08-15 13:28:36 +02:00
neckfire e741065e14 ci: exécute vitest dans le build Docker (RUN pnpm test) au lieu d'un bind-mount
Build & Deploy / build (push) Successful in 12s
2026-06-20 13:02:13 +02:00
neckfire 0a79af3738 ci: run vitest before build
Build & Deploy / build (push) Failing after 5s
2026-06-20 12:58:10 +02:00
neckfire bfdb3d3d9a test: vitest setup + auth/login/cookie banner tests 2026-06-20 12:57:13 +02:00
neckfire 7a4206a522 feat(auth): real JWT login, RBAC routes, RGPD cookie banner + users/journal admin
Build & Deploy / build (push) Successful in 11s
2026-06-20 12:54:01 +02:00
neckfire 4466b38f75 ci: dockerise le front (build Vite/pnpm -> nginx) + workflow Gitea Actions
- DataSentinel/Dockerfile (node 22 + pnpm + nginx) + nginx.conf (SPA + headers sécurité)
- workflow build/push image avec VITE_API_URL=https://datasentinel-api.nfteam.ovh
2026-06-20 12:27:51 +02:00
25 changed files with 1890 additions and 130 deletions
+41
View File
@@ -0,0 +1,41 @@
name: Build & Deploy
on:
push:
branches: [main]
workflow_dispatch: {}
env:
IMAGE: git.nfteam.ovh/neckfire/datasentinel-front
VITE_API_URL: https://datasentinel-api.nfteam.ovh
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Les tests vitest tournent pendant le build Docker (RUN pnpm test) :
# pas de bind-mount (incompatible docker-in-docker du runner).
- name: Build & push image
run: |
set -e
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker build --build-arg VITE_API_URL="${VITE_API_URL}" \
-t "${IMAGE}:latest" -t "${IMAGE}:${GITHUB_SHA::12}" ./DataSentinel
docker push --all-tags "${IMAGE}"
echo "pushed ${IMAGE}"
- name: Notify ntfy (success/failure)
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then EMOJI="white_check_mark"; PRIO="default"; else EMOJI="rotating_light"; PRIO="high"; fi
curl -s \
-H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" \
-H "Title: ${GITHUB_REPOSITORY} — ${{ job.status }}" \
-H "Priority: ${PRIO}" \
-H "Tags: ${EMOJI}" \
-H "Click: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions" \
-d "${GITHUB_WORKFLOW} sur ${GITHUB_REF_NAME} (run #${GITHUB_RUN_NUMBER}) : ${{ job.status }}" \
"${{ secrets.NTFY_URL }}/${{ secrets.NTFY_TOPIC }}" || true
+17
View File
@@ -0,0 +1,17 @@
# Front Data Sentinel — build Vite (pnpm) puis service statique via nginx.
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
# Tests (vitest) exécutés pendant le build : un test rouge casse le build.
RUN pnpm test
# URL de l'API injectée au build (Vite inline les import.meta.env.VITE_*).
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+14
View File
@@ -0,0 +1,14 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA : toutes les routes retombent sur index.html
location / {
try_files $uri $uri/ /index.html;
}
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
+9 -2
View File
@@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
@@ -25,6 +27,11 @@
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0", "globals": "^17.5.0",
"vite": "^8.0.10" "vite": "^8.0.10",
"vitest": "^4.1.9",
"jsdom": "^29.1.1",
"@testing-library/react": "^16.3.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/user-event": "^14.6.1"
} }
} }
+753 -4
View File
File diff suppressed because it is too large Load Diff
+35 -5
View File
@@ -12,14 +12,22 @@ import Fournisseur from './pages/Fournisseur/Fournisseur';
import Historique from './pages/Historique/Historique'; import Historique from './pages/Historique/Historique';
import Admin from './pages/Admin/Admin'; import Admin from './pages/Admin/Admin';
import Users from './pages/Users/Users'; import Users from './pages/Users/Users';
import Journal from './pages/Journal/Journal';
import Documentation from './pages/Documentation/Documentation'; import Documentation from './pages/Documentation/Documentation';
import MentionsLegales from './pages/MentionsLegales/MentionsLegales'; import MentionsLegales from './pages/MentionsLegales/MentionsLegales';
import CGU from './pages/CGU/CGU'; import CGU from './pages/CGU/CGU';
/* Composant pour protéger les routes nécessitant une authentification */ /* Composant pour protéger les routes nécessitant une authentification.
function ProtectedRoute({ children }) { * requireRole (optionnel) : restreint l'accès à un rôle précis (ex. "Admin"). */
function ProtectedRoute({ children, requireRole }) {
const { user } = useAuth(); const { user } = useAuth();
return user ? children : <Navigate to="/login" replace />; if (!user) {
return <Navigate to="/login" replace />;
}
if (requireRole && user.role !== requireRole) {
return <Navigate to="/dashboard" replace />;
}
return children;
} }
/* Composant pour les routes publiques (login) */ /* Composant pour les routes publiques (login) */
@@ -61,8 +69,30 @@ function App() {
<Route path="contrat" element={<Contrat />} /> <Route path="contrat" element={<Contrat />} />
<Route path="fournisseur" element={<Fournisseur />} /> <Route path="fournisseur" element={<Fournisseur />} />
<Route path="historique" element={<Historique />} /> <Route path="historique" element={<Historique />} />
<Route path="admin" element={<Admin />} /> <Route
<Route path="utilisateurs" element={<Users />} /> path="admin"
element={
<ProtectedRoute requireRole="Admin">
<Admin />
</ProtectedRoute>
}
/>
<Route
path="utilisateurs"
element={
<ProtectedRoute requireRole="Admin">
<Users />
</ProtectedRoute>
}
/>
<Route
path="journal"
element={
<ProtectedRoute requireRole="Admin">
<Journal />
</ProtectedRoute>
}
/>
<Route path="documentation" element={<Documentation />} /> <Route path="documentation" element={<Documentation />} />
</Route> </Route>
@@ -0,0 +1,49 @@
/* Tests du contexte d'authentification : login / logout */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { AuthProvider, useAuth } from '../context/AuthContext';
import * as api from '../services/api';
describe('AuthContext', () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
function wrapper({ children }) {
return <AuthProvider>{children}</AuthProvider>;
}
it('login renseigne l\'utilisateur, logout le vide', async () => {
const fakeUser = { username: 'bob', role: 'Consultant' };
vi.spyOn(api, 'login').mockResolvedValue(fakeUser);
const clearSpy = vi.spyOn(api, 'clearToken').mockImplementation(() => {});
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
await act(async () => {
await result.current.login('bob', 'pwd');
});
expect(result.current.user).toEqual(fakeUser);
expect(result.current.isAuthenticated).toBe(true);
/* L'utilisateur est persisté dans le localStorage */
expect(JSON.parse(localStorage.getItem('ds_user'))).toEqual(fakeUser);
act(() => {
result.current.logout();
});
expect(result.current.user).toBeNull();
expect(result.current.isAuthenticated).toBe(false);
expect(localStorage.getItem('ds_user')).toBeNull();
expect(clearSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,47 @@
/* Tests du bandeau de consentement aux cookies (RGPD) */
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import CookieBanner from '../components/common/CookieBanner';
function renderBanner() {
return render(
<MemoryRouter>
<CookieBanner />
</MemoryRouter>
);
}
describe('CookieBanner', () => {
beforeEach(() => {
localStorage.clear();
});
it('s\'affiche tant qu\'aucun consentement n\'est enregistré', () => {
renderBanner();
expect(screen.getByRole('dialog', { name: /Consentement aux cookies/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Tout accepter' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Refuser' })).toBeInTheDocument();
});
it('se masque et persiste le choix après "Tout accepter"', () => {
renderBanner();
fireEvent.click(screen.getByRole('button', { name: 'Tout accepter' }));
/* Le bandeau disparaît */
expect(screen.queryByRole('dialog', { name: /Consentement aux cookies/ })).not.toBeInTheDocument();
/* La décision est persistée au format attendu */
const stored = JSON.parse(localStorage.getItem('ds_cookies_consent'));
expect(stored.choice).toBe('accept');
expect(stored.version).toBe(1);
expect(typeof stored.date).toBe('string');
});
it('ne s\'affiche pas si un consentement existe déjà', () => {
localStorage.setItem('ds_cookies_consent', JSON.stringify({ choice: 'refuse', date: '2026-01-01', version: 1 }));
renderBanner();
expect(screen.queryByRole('dialog', { name: /Consentement aux cookies/ })).not.toBeInTheDocument();
});
});
+44
View File
@@ -0,0 +1,44 @@
/* Tests de la page de connexion : validation du captcha anti-bot */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Login from '../pages/Login/Login';
import { AuthProvider } from '../context/AuthContext';
import * as api from '../services/api';
function renderLogin() {
return render(
<MemoryRouter>
<AuthProvider>
<Login />
</AuthProvider>
</MemoryRouter>
);
}
describe('Login - captcha', () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('affiche une erreur et n\'appelle pas l\'API si le captcha est faux', async () => {
const loginSpy = vi.spyOn(api, 'login').mockResolvedValue({ username: 'x', role: 'Admin' });
renderLogin();
fireEvent.change(screen.getByLabelText('Identifiant'), { target: { value: 'alice' } });
fireEvent.change(screen.getByLabelText('Mot de passe'), { target: { value: 'pwd' } });
/* Réponse volontairement absurde, jamais égale à x + y (max 20) */
fireEvent.change(screen.getByLabelText(/combien font/i), { target: { value: '9999' } });
fireEvent.click(screen.getByRole('button', { name: /Se connecter/ }));
expect(await screen.findByText('Réponse au captcha incorrecte.')).toBeInTheDocument();
expect(loginSpy).not.toHaveBeenCalled();
});
});
+82
View File
@@ -0,0 +1,82 @@
/* Tests du service API : authentification et gestion du token JWT */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { login, getServices, getToken, setToken } from '../services/api';
describe('api.login', () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('stocke le token et envoie un corps urlencoded', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
access_token: 'jwt-abc-123',
token_type: 'bearer',
user: { username: 'alice', role: 'Admin' },
}),
});
vi.stubGlobal('fetch', fetchMock);
const user = await login('alice', 's3cret');
/* Le token est persisté */
expect(getToken()).toBe('jwt-abc-123');
/* L'utilisateur retourné provient de la réponse */
expect(user).toEqual({ username: 'alice', role: 'Admin' });
/* Vérifie l'URL, la méthode et l'en-tête de contenu */
const [url, options] = fetchMock.mock.calls[0];
expect(url).toMatch(/\/auth\/login$/);
expect(options.method).toBe('POST');
expect(options.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
/* Le corps urlencoded contient bien username et password */
const params = new URLSearchParams(options.body);
expect(params.get('username')).toBe('alice');
expect(params.get('password')).toBe('s3cret');
});
it('lève "Identifiants invalides" sur une réponse 401', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: async () => 'Unauthorized',
}));
await expect(login('alice', 'wrong')).rejects.toThrow('Identifiants invalides');
expect(getToken()).toBeNull();
});
});
describe('fetchApi (401)', () => {
beforeEach(() => {
localStorage.clear();
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('purge le token lorsqu\'une requête renvoie 401', async () => {
setToken('expired-token');
expect(getToken()).toBe('expired-token');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 401,
text: async () => 'Unauthorized',
}));
await expect(getServices()).rejects.toThrow();
/* Le token doit avoir été supprimé par handleUnauthorized */
expect(getToken()).toBeNull();
});
});
@@ -44,8 +44,8 @@ export default function Header({ onToggleSidebar, sidebarOpen }) {
<Bell size={18} color="var(--color-white)" aria-hidden="true" /> <Bell size={18} color="var(--color-white)" aria-hidden="true" />
</button> </button>
{user && ( {user && (
<span className={styles.username} aria-label={`Connecté en tant que ${user.nom}`}> <span className={styles.username} aria-label={`Connecté en tant que ${user.username}`}>
{user.nom} {user.username}
</span> </span>
)} )}
<button <button
@@ -3,6 +3,7 @@
import { useState } from 'react'; import { useState } from 'react';
import Header from './Header'; import Header from './Header';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import CookieBanner from '../common/CookieBanner';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import styles from './Layout.module.css'; import styles from './Layout.module.css';
@@ -19,6 +20,7 @@ export default function Layout() {
<main id="main-content" className={styles.main} tabIndex="-1"> <main id="main-content" className={styles.main} tabIndex="-1">
<Outlet /> <Outlet />
</main> </main>
<CookieBanner />
</div> </div>
); );
} }
+17 -9
View File
@@ -11,7 +11,9 @@ import {
Book, Book,
Shield, Shield,
FileText, FileText,
ScrollText,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import styles from './Sidebar.module.css'; import styles from './Sidebar.module.css';
/* Un lien de navigation avec icône */ /* Un lien de navigation avec icône */
@@ -31,6 +33,9 @@ function NavItem({ to, icon: Icon, label }) {
} }
export default function Sidebar({ isOpen, onClose }) { export default function Sidebar({ isOpen, onClose }) {
const { user } = useAuth();
const isAdmin = user?.role === 'Admin';
return ( return (
<> <>
{/* Overlay mobile pour fermer la sidebar */} {/* Overlay mobile pour fermer la sidebar */}
@@ -58,15 +63,18 @@ export default function Sidebar({ isOpen, onClose }) {
<NavItem to="/historique" icon={Clock} label="Historique" /> <NavItem to="/historique" icon={Clock} label="Historique" />
</div> </div>
<div className={styles.separator} role="separator" /> {/* Section administration : réservée aux administrateurs */}
{isAdmin && (
{/* Section administration */} <>
<div className={styles.sectionLabel} aria-hidden="true">ADMIN</div> <div className={styles.separator} role="separator" />
<div className={styles.section} role="group" aria-label="Administration"> <div className={styles.sectionLabel} aria-hidden="true">ADMIN</div>
<NavItem to="/admin" icon={Settings} label="Nomenclature" /> <div className={styles.section} role="group" aria-label="Administration">
<NavItem to="/utilisateurs" icon={Users} label="Utilisateurs" /> <NavItem to="/admin" icon={Settings} label="Nomenclature" />
<NavItem to="/utilisateurs" icon={Users} label="Utilisateurs" />
</div> <NavItem to="/journal" icon={ScrollText} label="Journal d'audit" />
</div>
</>
)}
</nav> </nav>
{/* Liens de pied de sidebar */} {/* Liens de pied de sidebar */}
@@ -0,0 +1,53 @@
/* Bandeau de consentement aux cookies (RGPD) */
import { useState } from 'react';
import { Link } from 'react-router-dom';
import styles from './CookieBanner.module.css';
const CONSENT_KEY = 'ds_cookies_consent';
const CONSENT_VERSION = 1;
export default function CookieBanner() {
/* Affiché tant qu'aucune décision n'a été enregistrée */
const [visible, setVisible] = useState(() => localStorage.getItem(CONSENT_KEY) === null);
if (!visible) {
return null;
}
/* Enregistre la décision de l'utilisateur (accept / refuse) */
function enregistrerChoix(choice) {
const decision = { choice, date: new Date().toISOString(), version: CONSENT_VERSION };
localStorage.setItem(CONSENT_KEY, JSON.stringify(decision));
setVisible(false);
}
return (
<div className={styles.banner} role="dialog" aria-label="Consentement aux cookies">
<div className={styles.content}>
<p className={styles.text}>
Nous utilisons des cookies pour assurer le bon fonctionnement de la plateforme.
Vous pouvez accepter ou refuser leur utilisation. Pour en savoir plus, consultez nos{' '}
<Link to="/mentions-legales" className={styles.link}>mentions légales</Link> et nos{' '}
<Link to="/cgu" className={styles.link}>CGU</Link>.
</p>
<div className={styles.actions}>
<button
type="button"
className={styles.btnRefuse}
onClick={() => enregistrerChoix('refuse')}
>
Refuser
</button>
<button
type="button"
className={styles.btnAccept}
onClick={() => enregistrerChoix('accept')}
>
Tout accepter
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
/* Bandeau de consentement aux cookies (RGPD) */
.banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
background-color: var(--color-white);
border-top: 3px solid var(--color-primary);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.2);
padding: 16px 24px;
}
.content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
}
.text {
font-size: 13px;
color: var(--color-text);
margin: 0;
flex: 1;
min-width: 240px;
}
.link {
color: var(--color-link);
text-decoration: underline;
}
.actions {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.btnAccept {
padding: 9px 18px;
background-color: var(--color-primary);
color: var(--color-white);
border: none;
border-radius: var(--radius);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s;
}
.btnAccept:hover {
background-color: var(--color-primary-dark);
}
.btnRefuse {
padding: 9px 18px;
background-color: var(--color-white);
color: var(--color-text);
border: 1px solid var(--color-gray-border);
border-radius: var(--radius);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s;
}
.btnRefuse:hover {
background-color: var(--color-danger-bg);
}
+12 -11
View File
@@ -1,32 +1,33 @@
/* Contexte d'authentification global : gestion de l'utilisateur connecté */ /* Contexte d'authentification global : gestion de l'utilisateur connecté */
import { createContext, useContext, useState } from 'react'; import { createContext, useContext, useState } from 'react';
import * as api from '../services/api';
const AuthContext = createContext(null); const AuthContext = createContext(null);
/* Utilisateur fictif stocké après connexion réussie (V1, pas de vrai auth) */
const USER_FICTIF = { nom: 'Coyaud', prenom: 'Anthony', role: 'Admin' };
const STORAGE_KEY = 'ds_user'; const STORAGE_KEY = 'ds_user';
/* Fournisseur du contexte d'authentification */ /* Fournisseur du contexte d'authentification */
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [user, setUser] = useState(() => { const [user, setUser] = useState(() => {
/* Restauration de la session depuis sessionStorage */ /* Restauration de la session depuis le localStorage au montage */
const stored = sessionStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null; return stored ? JSON.parse(stored) : null;
}); });
/* Connexion : stocke l'utilisateur fictif dans le state et sessionStorage, retourne true */ /* Connexion : appelle l'API, stocke le token + l'utilisateur retourné */
function login() { async function login(username, password) {
setUser(USER_FICTIF); const connectedUser = await api.login(username, password);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(USER_FICTIF)); setUser(connectedUser);
return true; localStorage.setItem(STORAGE_KEY, JSON.stringify(connectedUser));
return connectedUser;
} }
/* Déconnexion : vide le state et sessionStorage */ /* Déconnexion : vide le token, le state et le localStorage */
function logout() { function logout() {
api.clearToken();
setUser(null); setUser(null);
sessionStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
} }
/* Booléen pratique pour savoir si l'utilisateur est connecté */ /* Booléen pratique pour savoir si l'utilisateur est connecté */
+35 -10
View File
@@ -1,8 +1,8 @@
/* Page principale du dashboard : KPI cards + grille de monitorings */ /* Page principale du dashboard : KPI cards + grille de monitorings */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { getDashboard, getDashboardSummary, getServices, getCategories } from '../../services/api'; import { getDashboard, getDashboardSummary, getDashboardFiltres } from '../../services/api';
import MonitoringCard from '../../components/common/MonitoringCard'; import MonitoringCard from '../../components/common/MonitoringCard';
import LoadingSpinner from '../../components/common/LoadingSpinner'; import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage'; import ErrorMessage from '../../components/common/ErrorMessage';
@@ -22,19 +22,22 @@ export default function Dashboard() {
const [summary, setSummary] = useState(null); const [summary, setSummary] = useState(null);
const [monitorings, setMonitorings] = useState([]); const [monitorings, setMonitorings] = useState([]);
const [services, setServices] = useState([]); const [services, setServices] = useState([]);
const [categories, setCategories] = useState([]); const [combinaisons, setCombinaisons] = useState([]);
const [filtreService, setFiltreService] = useState(''); const [filtreService, setFiltreService] = useState('');
const [filtreCategorie, setFiltreCategorie] = useState(''); const [filtreCategorie, setFiltreCategorie] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [erreur, setErreur] = useState(''); const [erreur, setErreur] = useState('');
/* Chargement initial des filtres */ /* Chargement initial des filtres.
On n'utilise pas les référentiels SERVICE / CATEGORIE complets : ils
contiennent des entrées sans aucun monitoring rattaché (ex. « Business
Intelligence »), qui mèneraient sur un dashboard vide. */
useEffect(() => { useEffect(() => {
async function loadFiltres() { async function loadFiltres() {
try { try {
const [svcs, cats] = await Promise.all([getServices(), getCategories()]); const filtres = await getDashboardFiltres();
setServices(svcs); setServices(filtres.services);
setCategories(cats); setCombinaisons(filtres.combinaisons);
} catch { } catch {
/* Non bloquant, les filtres restent vides */ /* Non bloquant, les filtres restent vides */
} }
@@ -42,6 +45,28 @@ export default function Dashboard() {
loadFiltres(); loadFiltres();
}, []); }, []);
/* Catégories proposées : restreintes au service sélectionné, le cas échéant */
const categoriesDisponibles = useCallback(service => {
const pertinentes = service
? combinaisons.filter(c => c.service === service)
: combinaisons;
return [...new Set(pertinentes.map(c => c.categorie))].sort();
}, [combinaisons]);
const categories = useMemo(
() => categoriesDisponibles(filtreService),
[categoriesDisponibles, filtreService],
);
/* Changer de service peut rendre la catégorie sélectionnée sans résultat :
on la remet à zéro dans le même rendu, pour ne déclencher qu'un seul appel. */
function handleServiceChange(service) {
setFiltreService(service);
if (filtreCategorie && !categoriesDisponibles(service).includes(filtreCategorie)) {
setFiltreCategorie('');
}
}
/* Chargement des données dashboard */ /* Chargement des données dashboard */
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -105,11 +130,11 @@ export default function Dashboard() {
<select <select
className={styles.select} className={styles.select}
value={filtreService} value={filtreService}
onChange={e => setFiltreService(e.target.value)} onChange={e => handleServiceChange(e.target.value)}
> >
<option value="">Tous les services</option> <option value="">Tous les services</option>
{services.map(s => ( {services.map(s => (
<option key={s.id_service} value={s.nom_service}>{s.nom_service}</option> <option key={s} value={s}>{s}</option>
))} ))}
</select> </select>
<select <select
@@ -119,7 +144,7 @@ export default function Dashboard() {
> >
<option value="">Toutes les catégories</option> <option value="">Toutes les catégories</option>
{categories.map(c => ( {categories.map(c => (
<option key={c.id_categorie} value={c.intitule_categorie}>{c.intitule_categorie}</option> <option key={c} value={c}>{c}</option>
))} ))}
</select> </select>
</div> </div>
@@ -2,7 +2,7 @@
// Page Historique : filtres + graphique ligne + tableau résumé // Page Historique : filtres + graphique ligne + tableau résumé
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, ComposedChart } from 'recharts'; import { Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Area, ComposedChart } from 'recharts';
import { getMonitorings, getHistorique, getHistoriqueEvolution, getEvolutionGlobal, exportToCSV } from '../../services/api'; import { getMonitorings, getHistorique, getHistoriqueEvolution, getEvolutionGlobal, exportToCSV } from '../../services/api';
import DataTable from '../../components/common/DataTable'; import DataTable from '../../components/common/DataTable';
import LoadingSpinner from '../../components/common/LoadingSpinner'; import LoadingSpinner from '../../components/common/LoadingSpinner';
@@ -10,25 +10,66 @@ import ErrorMessage from '../../components/common/ErrorMessage';
import Button from '../../components/common/Button'; import Button from '../../components/common/Button';
import styles from './Historique.module.css'; import styles from './Historique.module.css';
/* Formate une date au format attendu par <input type="date"> (YYYY-MM-DD).
On lit les composantes en heure locale plutôt que d'utiliser toISOString(),
qui bascule en UTC : un 1er janvier saisi depuis Paris (UTC+1) y devient le
31 décembre de l'année précédente. */
function formatDateISO(date) {
const mois = String(date.getMonth() + 1).padStart(2, '0');
const jour = String(date.getDate()).padStart(2, '0');
return `${date.getFullYear()}-${mois}-${jour}`;
}
/* Période affichée par défaut : du 1er janvier de l'année courante à aujourd'hui. */
function periodeParDefaut() {
const aujourdhui = new Date();
return {
debut: formatDateISO(new Date(aujourdhui.getFullYear(), 0, 1)),
fin: formatDateISO(aujourdhui),
};
}
/* Récupère le tableau d'historique et la série du graphique pour les filtres
donnés. Sans monitoring sélectionné, on prend la courbe globale.
Fonction pure : elle ne touche à aucun état, ce qui permet de l'appeler
aussi bien depuis le bouton « Afficher » que depuis l'effet de montage. */
async function recupererHistorique(monitoring, debut, fin) {
const [histResult, evolResult] = monitoring
? await Promise.all([
getHistorique(monitoring, debut, fin),
getHistoriqueEvolution(monitoring, debut, fin),
])
: await Promise.all([
getHistorique(undefined, debut, fin),
getEvolutionGlobal(debut, fin),
]);
return {
hist: Array.isArray(histResult) ? histResult : [],
evol: Array.isArray(evolResult) ? evolResult : [],
};
}
export default function Historique() { export default function Historique() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
/* Figé au montage : sert à la fois de valeur initiale des filtres et de
paramètres du premier chargement, sans dépendance instable. */
const [valeursInitiales] = useState(() => ({
monitoring: searchParams.get('id') || '',
...periodeParDefaut(),
}));
const [monitorings, setMonitorings] = useState([]); const [monitorings, setMonitorings] = useState([]);
const [selectedMonitoring, setSelectedMonitoring] = useState(searchParams.get('id') || ''); const [selectedMonitoring, setSelectedMonitoring] = useState(valeursInitiales.monitoring);
const [dateDebut, setDateDebut] = useState(''); const [dateDebut, setDateDebut] = useState(valeursInitiales.debut);
const [dateFin, setDateFin] = useState(''); const [dateFin, setDateFin] = useState(valeursInitiales.fin);
const [historiqueData, setHistoriqueData] = useState([]); const [historiqueData, setHistoriqueData] = useState([]);
const [evolutionData, setEvolutionData] = useState([]); const [evolutionData, setEvolutionData] = useState([]);
const [loading, setLoading] = useState(false); /* Un chargement est lancé dès le montage : la page démarre donc en attente. */
const [loading, setLoading] = useState(true);
const [erreur, setErreur] = useState(''); const [erreur, setErreur] = useState('');
/* Set default dates to last 5 days */
useEffect(() => {
const today = new Date();
const fiveDaysAgo = new Date(today);
fiveDaysAgo.setDate(today.getDate() - 5);
setDateDebut(fiveDaysAgo.toISOString().split('T')[0]);
setDateFin(today.toISOString().split('T')[0]);
}, []);
useEffect(() => { useEffect(() => {
async function loadMonitorings() { async function loadMonitorings() {
try { try {
@@ -41,6 +82,31 @@ export default function Historique() {
loadMonitorings(); loadMonitorings();
}, []); }, []);
/* Premier affichage : on charge la période par défaut, pour que la page ne
s'ouvre pas sur un écran vide en attente d'un clic. Le drapeau `annule`
évite d'écrire dans un composant déjà démonté si l'utilisateur quitte la
page pendant la requête. */
useEffect(() => {
let annule = false;
async function chargerAuMontage() {
const { monitoring, debut, fin } = valeursInitiales;
try {
const { hist, evol } = await recupererHistorique(monitoring, debut, fin);
if (annule) return;
setHistoriqueData(hist);
setEvolutionData(evol);
} catch (e) {
if (!annule) setErreur(e.message || 'Erreur lors du chargement de l\'historique.');
} finally {
if (!annule) setLoading(false);
}
}
chargerAuMontage();
return () => { annule = true; };
}, [valeursInitiales]);
async function handleAfficher() { async function handleAfficher() {
if (!dateDebut || !dateFin) { if (!dateDebut || !dateFin) {
setErreur('Veuillez sélectionner des dates.'); setErreur('Veuillez sélectionner des dates.');
@@ -50,25 +116,7 @@ export default function Historique() {
setLoading(true); setLoading(true);
setErreur(''); setErreur('');
try { try {
let hist = []; const { hist, evol } = await recupererHistorique(selectedMonitoring, dateDebut, dateFin);
let evol = [];
if (selectedMonitoring) {
const [histResult, evolResult] = await Promise.all([
getHistorique(selectedMonitoring, dateDebut, dateFin),
getHistoriqueEvolution(selectedMonitoring, dateDebut, dateFin)
]);
hist = Array.isArray(histResult) ? histResult : [];
evol = Array.isArray(evolResult) ? evolResult : [];
} else {
const [histResult, evolResult] = await Promise.all([
getHistorique(undefined, dateDebut, dateFin),
getEvolutionGlobal(dateDebut, dateFin)
]);
hist = Array.isArray(histResult) ? histResult : [];
evol = Array.isArray(evolResult) ? evolResult : [];
}
setHistoriqueData(hist); setHistoriqueData(hist);
setEvolutionData(evol); setEvolutionData(evol);
} catch (e) { } catch (e) {
@@ -189,9 +237,9 @@ export default function Historique() {
emptyMessage="Aucune donnée historique." emptyMessage="Aucune donnée historique."
/> />
</> </>
) : selectedMonitoring && dateDebut && dateFin && !loading ? ( ) : (
<p className={styles.empty}>Aucune donnée trouvée pour cette période.</p> <p className={styles.empty}>Aucune donnée trouvée pour cette période.</p>
) : null} )}
</div> </div>
); );
} }
+1 -1
View File
@@ -30,7 +30,7 @@ export default function Journal() {
const columns = [ const columns = [
{ key: 'date_action', label: 'Date' }, { key: 'date_action', label: 'Date' },
{ key: 'utilisateur', label: 'Utilisateur' }, { key: 'username', label: 'Utilisateur' },
{ key: 'action', label: 'Action' }, { key: 'action', label: 'Action' },
{ key: 'detail', label: 'Détail' }, { key: 'detail', label: 'Détail' },
{ key: 'ip', label: 'Adresse IP' }, { key: 'ip', label: 'Adresse IP' },
+41 -21
View File
@@ -1,14 +1,23 @@
/* Page de connexion : formulaire + SSO Windows */ /* Page de connexion : formulaire avec authentification JWT + captcha anti-bot */
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import { getHealth } from '../../services/api';
import styles from './Login.module.css'; import styles from './Login.module.css';
/* Génère deux entiers aléatoires pour le captcha additif */
function genererCaptcha() {
return {
x: Math.floor(Math.random() * 10) + 1,
y: Math.floor(Math.random() * 10) + 1,
};
}
export default function Login() { export default function Login() {
const [identifiant, setIdentifiant] = useState(''); const [identifiant, setIdentifiant] = useState('');
const [motDePasse, setMotDePasse] = useState(''); const [motDePasse, setMotDePasse] = useState('');
const [captcha, setCaptcha] = useState(() => genererCaptcha());
const [reponseCaptcha, setReponseCaptcha] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [erreur, setErreur] = useState(''); const [erreur, setErreur] = useState('');
@@ -18,25 +27,29 @@ export default function Login() {
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
setErreur(''); setErreur('');
setLoading(true);
/* Vérification anti-bot du captcha avant tout appel réseau */
if (parseInt(reponseCaptcha, 10) !== captcha.x + captcha.y) {
setErreur('Réponse au captcha incorrecte.');
setCaptcha(genererCaptcha());
setReponseCaptcha('');
return;
}
setLoading(true);
try { try {
/* Vérification de la disponibilité de l'API avant connexion */ await login(identifiant, motDePasse);
await getHealth();
login({ nom: identifiant || 'Utilisateur', role: 'user', token: 'session' });
navigate('/dashboard'); navigate('/dashboard');
} catch { } catch (err) {
setErreur('Impossible de contacter le serveur. Vérifiez que l\'API est démarrée sur le port 8000.'); /* Message générique en cas d'identifiants invalides ou d'erreur serveur */
setErreur(err.message === 'Identifiants invalides' ? 'Identifiants invalides' : (err.message || 'Identifiants invalides'));
setCaptcha(genererCaptcha());
setReponseCaptcha('');
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
function handleSso() {
/* Connexion SSO Windows : même vérification API */
handleSubmit({ preventDefault: () => {} });
}
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.card}> <div className={styles.card}>
@@ -73,6 +86,21 @@ export default function Login() {
/> />
</div> </div>
<div className={styles.field}>
<label className={styles.label} htmlFor="captcha">
Vérification : combien font {captcha.x} + {captcha.y} ?
</label>
<input
id="captcha"
type="number"
className={styles.input}
value={reponseCaptcha}
onChange={e => setReponseCaptcha(e.target.value)}
placeholder="Votre réponse"
autoComplete="off"
/>
</div>
{erreur && <p className={styles.erreur}>{erreur}</p>} {erreur && <p className={styles.erreur}>{erreur}</p>}
<button type="submit" className={styles.btnPrimary} disabled={loading}> <button type="submit" className={styles.btnPrimary} disabled={loading}>
@@ -80,14 +108,6 @@ export default function Login() {
</button> </button>
</form> </form>
<div className={styles.divider}>
<span>ou</span>
</div>
<button type="button" className={styles.btnSecondary} onClick={handleSso} disabled={loading}>
Connexion Windows SSO
</button>
<div className={styles.links}> <div className={styles.links}>
<a href="#" className={styles.forgotLink}>Mot de passe oublié ?</a> <a href="#" className={styles.forgotLink}>Mot de passe oublié ?</a>
</div> </div>
+226 -29
View File
@@ -1,76 +1,273 @@
// src/pages/Users/Users.jsx // src/pages/Users/Users.jsx
// Page Utilisateurs : gestion des utilisateurs (simulation) // Page Utilisateurs : gestion réelle des utilisateurs via l'API admin
import { useState } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Plus, Edit, Trash2 } from 'lucide-react'; import { Plus, Edit, Trash2, KeyRound } from 'lucide-react';
import DataTable from '../../components/common/DataTable'; import DataTable from '../../components/common/DataTable';
import Button from '../../components/common/Button'; import Button from '../../components/common/Button';
import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage';
import {
getUsers,
createUser,
updateUser,
deleteUser,
resetUserPassword,
} from '../../services/api';
import styles from './Users.module.css'; import styles from './Users.module.css';
/* Données simulées pour les utilisateurs */ /* Rôles disponibles côté backend */
const mockUsers = [ const ROLES = ['Admin', 'Superviseur', 'Consultant'];
{ id: 1, nom: 'Alice Dupont', email: 'alice@xefi.fr', role: 'Admin', actif: true },
{ id: 2, nom: 'Bob Martin', email: 'bob@xefi.fr', role: 'Utilisateur', actif: true },
{ id: 3, nom: 'Charlie Durand', email: 'charlie@xefi.fr', role: 'Utilisateur', actif: false }
];
export default function Users() { export default function Users() {
const [users, setUsers] = useState(mockUsers); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [erreur, setErreur] = useState('');
function handleAdd() { /* Modale active : 'create' | 'edit' | 'reset' | null */
console.log('Ajouter utilisateur'); const [modal, setModal] = useState(null);
const [selected, setSelected] = useState(null);
const [form, setForm] = useState({});
const [formError, setFormError] = useState('');
const [submitting, setSubmitting] = useState(false);
const charger = useCallback(async () => {
setLoading(true);
setErreur('');
try {
const data = await getUsers();
setUsers(Array.isArray(data) ? data : []);
} catch (e) {
setErreur(e.message || 'Erreur lors du chargement des utilisateurs.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
charger();
}, [charger]);
/* Ouverture des modales */
function ouvrirCreation() {
setSelected(null);
setForm({ username: '', email: '', password: '', role: 'Consultant' });
setFormError('');
setModal('create');
} }
function handleEdit(user) { function ouvrirEdition(user) {
console.log('Modifier utilisateur', user); setSelected(user);
setForm({ email: user.email ?? '', role: user.role ?? 'Consultant', actif: !!user.actif });
setFormError('');
setModal('edit');
} }
function handleDelete(user) { function ouvrirReset(user) {
setUsers(prev => prev.map(u => u.id === user.id ? { ...u, actif: false } : u)); setSelected(user);
setForm({ password: '' });
setFormError('');
setModal('reset');
}
function fermerModale() {
setModal(null);
setSelected(null);
setForm({});
setFormError('');
}
/* Suppression (soft delete) avec confirmation */
async function handleDelete(user) {
if (!window.confirm(`Désactiver l'utilisateur "${user.username}" ?`)) {
return;
}
try {
await deleteUser(user.id_user);
await charger();
} catch (e) {
setErreur(e.message || 'Erreur lors de la suppression.');
}
}
/* Soumission de la modale courante */
async function handleSubmit(e) {
e.preventDefault();
setFormError('');
setSubmitting(true);
try {
if (modal === 'create') {
await createUser({
username: form.username,
email: form.email,
password: form.password,
role: form.role,
});
} else if (modal === 'edit') {
await updateUser(selected.id_user, {
email: form.email,
role: form.role,
actif: form.actif,
});
} else if (modal === 'reset') {
await resetUserPassword(selected.id_user, form.password);
}
fermerModale();
await charger();
} catch (e) {
setFormError(e.message || 'Une erreur est survenue.');
} finally {
setSubmitting(false);
}
} }
const columns = [ const columns = [
{ key: 'nom', label: 'Nom' }, { key: 'username', label: 'Identifiant' },
{ key: 'email', label: 'Email' }, { key: 'email', label: 'Email' },
{ key: 'role', label: 'Rôle' }, { key: 'role', label: 'Rôle' },
{ {
key: 'actif', key: 'actif',
label: 'Statut', label: 'Statut',
render: (val) => val ? 'Actif' : 'Inactif' render: (val) => (val ? 'Actif' : 'Inactif'),
}, },
{ {
key: 'actions', key: 'actions',
label: 'Actions', label: 'Actions',
render: (val, row) => ( render: (val, row) => (
<div className={styles.actions}> <div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => handleEdit(row)}> <button className={styles.actionBtn} onClick={() => ouvrirEdition(row)}>
<Edit size={14} /> <Edit size={14} />
Modifier Modifier
</button> </button>
<button className={styles.actionBtn} onClick={() => ouvrirReset(row)}>
<KeyRound size={14} />
Mot de passe
</button>
<button className={styles.actionBtn} onClick={() => handleDelete(row)}> <button className={styles.actionBtn} onClick={() => handleDelete(row)}>
<Trash2 size={14} /> <Trash2 size={14} />
Désactiver Désactiver
</button> </button>
</div> </div>
) ),
} },
]; ];
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.header}> <div className={styles.header}>
<h1 className={styles.title}>Utilisateurs</h1> <h1 className={styles.title}>Utilisateurs</h1>
<Button variant="primary" onClick={handleAdd}> <Button variant="primary" onClick={ouvrirCreation}>
<Plus size={14} /> <Plus size={14} />
Ajouter Ajouter
</Button> </Button>
</div> </div>
<DataTable {loading ? (
columns={columns} <LoadingSpinner message="Chargement des utilisateurs..." />
data={users} ) : erreur ? (
pageSize={50} <ErrorMessage message={erreur} onRetry={charger} />
emptyMessage="Aucun utilisateur trouvé." ) : (
/> <DataTable
columns={columns}
data={users.map(u => ({ ...u, id: u.id_user }))}
pageSize={50}
emptyMessage="Aucun utilisateur trouvé."
/>
)}
{modal && (
<div className={styles.overlay} role="dialog" aria-modal="true" onClick={fermerModale}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<h2 className={styles.modalTitle}>
{modal === 'create' && 'Nouvel utilisateur'}
{modal === 'edit' && `Modifier ${selected?.username}`}
{modal === 'reset' && `Réinitialiser le mot de passe de ${selected?.username}`}
</h2>
<form className={styles.form} onSubmit={handleSubmit}>
{modal === 'create' && (
<div className={styles.field}>
<label className={styles.label} htmlFor="username">Identifiant</label>
<input
id="username"
type="text"
className={styles.input}
value={form.username}
onChange={e => setForm(f => ({ ...f, username: e.target.value }))}
required
/>
</div>
)}
{(modal === 'create' || modal === 'edit') && (
<div className={styles.field}>
<label className={styles.label} htmlFor="email">Email</label>
<input
id="email"
type="email"
className={styles.input}
value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
required
/>
</div>
)}
{(modal === 'create' || modal === 'reset') && (
<div className={styles.field}>
<label className={styles.label} htmlFor="password">Mot de passe</label>
<input
id="password"
type="password"
className={styles.input}
value={form.password}
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
required
/>
</div>
)}
{(modal === 'create' || modal === 'edit') && (
<div className={styles.field}>
<label className={styles.label} htmlFor="role">Rôle</label>
<select
id="role"
className={styles.input}
value={form.role}
onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
>
{ROLES.map(r => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
)}
{modal === 'edit' && (
<div className={styles.checkboxField}>
<input
id="actif"
type="checkbox"
checked={form.actif}
onChange={e => setForm(f => ({ ...f, actif: e.target.checked }))}
/>
<label htmlFor="actif">Compte actif</label>
</div>
)}
{formError && <p className={styles.formError}>{formError}</p>}
<div className={styles.modalActions}>
<Button variant="neutral" type="button" onClick={fermerModale} disabled={submitting}>
Annuler
</Button>
<Button variant="primary" type="submit" disabled={submitting}>
{submitting ? 'Enregistrement...' : 'Enregistrer'}
</Button>
</div>
</form>
</div>
</div>
)}
</div> </div>
); );
} }
@@ -40,4 +40,90 @@
.actionBtn:hover { .actionBtn:hover {
color: var(--color-primary); color: var(--color-primary);
}
/* Modale de création / édition / réinitialisation */
.overlay {
position: fixed;
inset: 0;
z-index: 1100;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.modal {
background-color: var(--color-white);
width: 100%;
max-width: 440px;
border-radius: var(--radius);
border-top: 4px solid var(--color-primary);
padding: 28px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modalTitle {
font-size: 20px;
font-weight: 600;
color: var(--color-text);
margin: 0 0 20px;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 13px;
font-weight: 600;
color: var(--color-text);
}
.input {
padding: 10px 12px;
border: 1px solid var(--color-gray-border);
border-radius: var(--radius);
font-size: 14px;
color: var(--color-text);
outline: none;
transition: border-color 0.15s;
}
.input:focus {
border-color: var(--color-primary);
}
.checkboxField {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--color-text);
}
.formError {
font-size: 13px;
color: var(--color-primary);
background-color: var(--color-danger-bg);
border-left: 3px solid var(--color-primary);
padding: 8px 12px;
border-radius: var(--radius);
margin: 0;
}
.modalActions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
} }
+156 -2
View File
@@ -3,7 +3,44 @@
/* Utilise la variable d'environnement Vite si définie, sinon localhost par défaut */ /* Utilise la variable d'environnement Vite si définie, sinon localhost par défaut */
const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
/* Fonction utilitaire pour les requêtes fetch avec gestion d'erreur */ /* Clés de stockage local pour le token JWT et l'utilisateur connecté */
const TOKEN_KEY = 'ds_token';
const USER_KEY = 'ds_user';
/* Helpers de gestion du token JWT dans le localStorage */
export function getToken() {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
/* Construit les en-têtes d'authentification si un token est présent */
function authHeaders(extra = {}) {
const token = getToken();
const headers = { ...extra };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return headers;
}
/* Gestion d'une réponse 401 : purge de la session et redirection vers /login */
function handleUnauthorized() {
clearToken();
localStorage.removeItem(USER_KEY);
/* Évite une boucle de redirection si on est déjà sur la page de connexion */
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
/* Fonction utilitaire pour les requêtes fetch (GET) avec gestion d'erreur et auth */
async function fetchApi(path, params = {}) { async function fetchApi(path, params = {}) {
const url = new URL(`${BASE_URL}${path}`); const url = new URL(`${BASE_URL}${path}`);
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
@@ -13,7 +50,11 @@ async function fetchApi(path, params = {}) {
}); });
try { try {
const response = await fetch(url.toString()); const response = await fetch(url.toString(), { headers: authHeaders() });
if (response.status === 401) {
handleUnauthorized();
throw new Error('Session expirée, veuillez vous reconnecter.');
}
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error(`Erreur API [${response.status}] ${path}: ${errorText}`); console.error(`Erreur API [${response.status}] ${path}: ${errorText}`);
@@ -26,6 +67,107 @@ async function fetchApi(path, params = {}) {
} }
} }
/* Helper générique pour les requêtes JSON (POST / PUT / DELETE) avec auth */
async function sendJson(path, method, body) {
const url = `${BASE_URL}${path}`;
const options = {
method,
headers: authHeaders(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
};
if (body !== undefined) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(url, options);
if (response.status === 401) {
handleUnauthorized();
throw new Error('Session expirée, veuillez vous reconnecter.');
}
if (!response.ok) {
const errorText = await response.text();
console.error(`Erreur API [${response.status}] ${path}: ${errorText}`);
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
/* Certaines réponses (204, DELETE) peuvent ne pas contenir de corps JSON */
const text = await response.text();
return text ? JSON.parse(text) : null;
} catch (error) {
console.error(`Erreur lors de l'appel API ${path}:`, error);
throw error;
}
}
/* Authentification : POST /auth/login en x-www-form-urlencoded (OAuth2) */
export async function login(username, password) {
const body = new URLSearchParams();
body.append('username', username);
body.append('password', password);
const response = await fetch(`${BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (response.status === 401) {
throw new Error('Identifiants invalides');
}
if (response.status === 429) {
throw new Error('Trop de tentatives. Veuillez réessayer dans une minute.');
}
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Erreur ${response.status}: ${errorText}`);
}
const data = await response.json();
setToken(data.access_token);
return data.user;
}
/* Récupère le profil de l'utilisateur connecté */
export async function getMe() {
return fetchApi('/auth/me');
}
/* ---------- Administration des utilisateurs ---------- */
export async function getUsers() {
return fetchApi('/admin/users');
}
export async function createUser({ username, email, password, role }) {
return sendJson('/admin/users', 'POST', { username, email, password, role });
}
export async function updateUser(id_user, changes) {
return sendJson(`/admin/users/${id_user}`, 'PUT', changes);
}
export async function deleteUser(id_user) {
return sendJson(`/admin/users/${id_user}`, 'DELETE');
}
export async function resetUserPassword(id_user, password) {
return sendJson(`/admin/users/${id_user}/reset-password`, 'POST', { password });
}
/* Journal d'audit */
export async function getJournal(limit = 200) {
return fetchApi('/admin/journal', { limit });
}
/* ---------- RGPD : données personnelles ---------- */
export async function exportMyData() {
return fetchApi('/me/data-export');
}
export async function deleteMyAccount() {
return sendJson('/me', 'DELETE');
}
/* Santé de l'API */ /* Santé de l'API */
export async function getHealth() { export async function getHealth() {
return fetchApi('/health'); return fetchApi('/health');
@@ -103,6 +245,18 @@ export async function getDashboard(service, categorie) {
})) : data; })) : data;
} }
/* Récupère les valeurs de filtre qui ramènent au moins un monitoring (VUE_CONSO).
Évite de proposer un service ou une catégorie du référentiel qui n'a aucun
monitoring rattaché, et donc de mener l'utilisateur sur un écran vide. */
export async function getDashboardFiltres() {
const data = await fetchApi('/dashboard/filtres');
return {
services: Array.isArray(data?.services) ? data.services : [],
categories: Array.isArray(data?.categories) ? data.categories : [],
combinaisons: Array.isArray(data?.combinaisons) ? data.combinaisons : [],
};
}
/* Récupère le résumé global KPI du dashboard */ /* Récupère le résumé global KPI du dashboard */
export async function getDashboardSummary() { export async function getDashboardSummary() {
const data = await fetchApi('/dashboard/summary'); const data = await fetchApi('/dashboard/summary');
+7
View File
@@ -0,0 +1,7 @@
/* Configuration globale des tests : matchers jest-dom + nettoyage du localStorage */
import '@testing-library/jest-dom';
import { afterEach } from 'vitest';
afterEach(() => {
localStorage.clear();
});
+5
View File
@@ -6,4 +6,9 @@ export default defineConfig({
plugins: [ plugins: [
react(), react(),
], ],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.js',
},
}) })