- 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
317 lines
10 KiB
TypeScript
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;
|