Files
facturation-santinova/backend/src/routes/auth.ts
Manus Agent 1fb8328fe1 fix: conformité stricte skill itinova-user-management
- Rôles : remplacement admin/approbateur/validateur/operateur → admin/standard/readonly
- schema.ts, migrate.ts : ENUM MySQL mis à jour (3 rôles skill)
- routes/auth.ts : rôle par défaut standard, validRoles, modèle CSV corrigé
- routes/invoices.ts : permissions readonly/standard/admin
- routes/dashboard.ts : compteurs dashboard selon standard/admin
- frontend/types/index.ts : type User role mis à jour
- frontend/utils/helpers.ts : roleLabels admin/standard/readonly
- frontend/pages/InvoiceDetail.tsx : actions disponibles selon standard/readonly/admin
- frontend/pages/UserList.tsx : rôle par défaut standard, labels import corrigés
2026-04-28 04:27:46 -04:00

317 lines
10 KiB
TypeScript

import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import multer from 'multer';
import * as XLSX from 'xlsx';
import { getPool } from '../config/database';
import { authenticate, AuthRequest, authorize } from '../middleware/auth';
// Multer en mémoire pour l'import CSV/XLSX
const uploadMemory = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 Mo max
fileFilter: (_req, file, cb) => {
const allowed = [
'text/csv',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'text/plain',
];
if (allowed.includes(file.mimetype) || file.originalname.match(/\.(csv|xlsx|xls)$/i)) {
cb(null, true);
} else {
cb(new Error('Format non supporté. Utilisez .csv ou .xlsx'));
}
},
});
const router = Router();
// POST /api/auth/login
router.post('/login', async (req: Request, res: Response) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email et mot de passe requis' });
}
const pool = getPool();
const [rows]: any = await pool.execute(
'SELECT * FROM users WHERE email = ? AND is_active = TRUE',
[email]
);
if (!rows.length) {
return res.status(401).json({ error: 'Identifiants incorrects' });
}
const user = rows[0];
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Identifiants incorrects' });
}
const secret = process.env.JWT_SECRET || 'santinova-jwt-secret';
const expiresIn = process.env.JWT_EXPIRES_IN || '24h';
const token = (jwt.sign as any)(
{
id: user.id,
email: user.email,
role: user.role,
firstName: user.first_name,
lastName: user.last_name,
},
secret,
{ expiresIn }
);
res.json({
token,
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role,
},
});
} catch (error: any) {
console.error('Erreur login:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// GET /api/auth/me
router.get('/me', authenticate, async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const [rows]: any = await pool.execute(
'SELECT id, email, first_name, last_name, role, is_active, created_at FROM users WHERE id = ?',
[req.user!.id]
);
if (!rows.length) {
return res.status(404).json({ error: 'Utilisateur non trouvé' });
}
const user = rows[0];
res.json({
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role,
isActive: user.is_active,
createdAt: user.created_at,
});
} catch (error: any) {
console.error('Erreur me:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// GET /api/auth/users - Liste des utilisateurs (admin)
router.get('/users', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
try {
const pool = getPool();
const [rows]: any = await pool.execute(
'SELECT id, email, first_name, last_name, role, is_active, created_at, updated_at FROM users ORDER BY created_at DESC'
);
const users = rows.map((u: any) => ({
id: u.id,
email: u.email,
firstName: u.first_name,
lastName: u.last_name,
role: u.role,
isActive: u.is_active,
createdAt: u.created_at,
updatedAt: u.updated_at,
}));
res.json(users);
} catch (error: any) {
console.error('Erreur liste users:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// POST /api/auth/users - Créer un utilisateur (admin)
router.post('/users', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
try {
const { email, password, firstName, lastName, role } = req.body;
if (!email || !password || !firstName || !lastName) {
return res.status(400).json({ error: 'Tous les champs sont requis' });
}
const hashedPassword = await bcrypt.hash(password, 12);
const pool = getPool();
const [result]: any = await pool.execute(
'INSERT INTO users (email, password, first_name, last_name, role) VALUES (?, ?, ?, ?, ?)',
[email, hashedPassword, firstName, lastName, role || 'standard']
);
res.status(201).json({
id: result.insertId,
email,
firstName,
lastName,
role: role || 'standard',
});
} catch (error: any) {
if (error.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'Cet email est déjà utilisé' });
}
console.error('Erreur création user:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// PUT /api/auth/users/:id - Modifier un utilisateur (admin)
router.put('/users/:id', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const { email, firstName, lastName, role, isActive, password } = req.body;
const pool = getPool();
let query = 'UPDATE users SET email = ?, first_name = ?, last_name = ?, role = ?, is_active = ?';
let params: any[] = [email, firstName, lastName, role, isActive !== false];
if (password) {
const hashedPassword = await bcrypt.hash(password, 12);
query += ', password = ?';
params.push(hashedPassword);
}
query += ' WHERE id = ?';
params.push(id);
await pool.execute(query, params);
res.json({ message: 'Utilisateur mis à jour' });
} catch (error: any) {
console.error('Erreur update user:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// DELETE /api/auth/users/:id - Désactiver un utilisateur (admin)
router.delete('/users/:id', authenticate, authorize('admin'), async (req: AuthRequest, res: Response) => {
try {
const { id } = req.params;
const pool = getPool();
await pool.execute('UPDATE users SET is_active = FALSE WHERE id = ?', [id]);
res.json({ message: 'Utilisateur désactivé' });
} catch (error: any) {
console.error('Erreur delete user:', error);
res.status(500).json({ error: 'Erreur serveur' });
}
});
// GET /api/auth/users/import-template - Télécharger le modèle CSV
router.get('/users/import-template', authenticate, authorize('admin'), (_req: AuthRequest, res: Response) => {
const csvContent = 'email,prenom,nom,role\nadmin@exemple.com,Jean,Dupont,admin\nstandard@exemple.com,Marie,Martin,standard\nreadonly@exemple.com,Paul,Durand,readonly\n';
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="modele_import_utilisateurs.csv"');
res.send('\uFEFF' + csvContent); // BOM UTF-8 pour Excel
});
// POST /api/auth/users/import - Import CSV/Excel utilisateurs (admin)
router.post('/users/import', authenticate, authorize('admin'), uploadMemory.single('file'), async (req: AuthRequest, res: Response) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Aucun fichier fourni' });
}
const pool = getPool();
const validRoles = ['admin', 'standard', 'readonly'];
const results = { created: 0, updated: 0, errors: [] as string[] };
// Parsing CSV ou Excel
let rows: any[][] = [];
const ext = req.file.originalname.toLowerCase();
if (ext.endsWith('.csv') || req.file.mimetype === 'text/csv' || req.file.mimetype === 'text/plain') {
// Parsing CSV
const text = req.file.buffer.toString('utf-8').replace(/^\uFEFF/, ''); // supprimer BOM
const lines = text.split(/\r?\n/).filter((l) => l.trim());
rows = lines.map((line) => line.split(/[,;]/));
} else {
// Parsing Excel
const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) as any[][];
}
if (rows.length < 2) {
return res.status(400).json({ error: 'Le fichier est vide ou ne contient que l\'en-tête' });
}
// Ignorer la ligne d'en-tête
const dataRows = rows.slice(1);
for (let i = 0; i < dataRows.length; i++) {
const row = dataRows[i];
const lineNum = i + 2;
const email = String(row[0] || '').trim().toLowerCase();
const prenom = String(row[1] || '').trim();
const nom = String(row[2] || '').trim();
const role = String(row[3] || 'standard').trim().toLowerCase();
// Validation
if (!email || !email.includes('@')) {
results.errors.push(`Ligne ${lineNum} : email invalide ("${email}")`);
continue;
}
if (!prenom || !nom) {
results.errors.push(`Ligne ${lineNum} : prénom ou nom manquant`);
continue;
}
const finalRole = validRoles.includes(role) ? role : 'standard';
// Vérifier si l'utilisateur existe
const [existing]: any = await pool.execute(
'SELECT id FROM users WHERE email = ?',
[email]
);
if (existing.length > 0) {
// Mise à jour
await pool.execute(
'UPDATE users SET first_name = ?, last_name = ?, role = ?, is_active = TRUE WHERE email = ?',
[prenom, nom, finalRole, email]
);
results.updated++;
} else {
// Création avec mot de passe temporaire
const tempPassword = Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-4).toUpperCase() + '!';
const hashedPassword = await bcrypt.hash(tempPassword, 12);
await pool.execute(
'INSERT INTO users (email, password, first_name, last_name, role, is_active) VALUES (?, ?, ?, ?, ?, TRUE)',
[email, hashedPassword, prenom, nom, finalRole]
);
results.created++;
}
}
res.json({
message: `Import terminé : ${results.created} créé(s), ${results.updated} mis à jour, ${results.errors.length} erreur(s)`,
...results,
});
} catch (error: any) {
console.error('Erreur import users:', error);
res.status(500).json({ error: error.message || 'Erreur serveur lors de l\'import' });
}
});
export default router;