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
This commit is contained in:
@@ -31,7 +31,7 @@ async function migrate() {
|
|||||||
password VARCHAR(255) NOT NULL,
|
password VARCHAR(255) NOT NULL,
|
||||||
first_name VARCHAR(100) NOT NULL,
|
first_name VARCHAR(100) NOT NULL,
|
||||||
last_name VARCHAR(100) NOT NULL,
|
last_name VARCHAR(100) NOT NULL,
|
||||||
role ENUM('admin', 'approbateur', 'validateur', 'operateur') NOT NULL DEFAULT 'operateur',
|
role ENUM('admin', 'standard', 'readonly') NOT NULL DEFAULT 'standard',
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const users = mysqlTable('users', {
|
|||||||
password: varchar('password', { length: 255 }).notNull(),
|
password: varchar('password', { length: 255 }).notNull(),
|
||||||
firstName: varchar('first_name', { length: 100 }).notNull(),
|
firstName: varchar('first_name', { length: 100 }).notNull(),
|
||||||
lastName: varchar('last_name', { length: 100 }).notNull(),
|
lastName: varchar('last_name', { length: 100 }).notNull(),
|
||||||
role: mysqlEnum('role', ['admin', 'approbateur', 'validateur', 'operateur']).notNull().default('operateur'),
|
role: mysqlEnum('role', ['admin', 'standard', 'readonly']).notNull().default('standard'),
|
||||||
isActive: boolean('is_active').notNull().default(true),
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
|
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow().notNull(),
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ router.post('/users', authenticate, authorize('admin'), async (req: AuthRequest,
|
|||||||
|
|
||||||
const [result]: any = await pool.execute(
|
const [result]: any = await pool.execute(
|
||||||
'INSERT INTO users (email, password, first_name, last_name, role) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO users (email, password, first_name, last_name, role) VALUES (?, ?, ?, ?, ?)',
|
||||||
[email, hashedPassword, firstName, lastName, role || 'operateur']
|
[email, hashedPassword, firstName, lastName, role || 'standard']
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
@@ -161,7 +161,7 @@ router.post('/users', authenticate, authorize('admin'), async (req: AuthRequest,
|
|||||||
email,
|
email,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
role: role || 'operateur',
|
role: role || 'standard',
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.code === 'ER_DUP_ENTRY') {
|
if (error.code === 'ER_DUP_ENTRY') {
|
||||||
@@ -218,7 +218,7 @@ router.delete('/users/:id', authenticate, authorize('admin'), async (req: AuthRe
|
|||||||
|
|
||||||
// GET /api/auth/users/import-template - Télécharger le modèle CSV
|
// 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) => {
|
router.get('/users/import-template', authenticate, authorize('admin'), (_req: AuthRequest, res: Response) => {
|
||||||
const csvContent = 'email,prenom,nom,role\nadmin@exemple.com,Jean,Dupont,admin\nutilisateur@exemple.com,Marie,Martin,operateur\n';
|
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-Type', 'text/csv; charset=utf-8');
|
||||||
res.setHeader('Content-Disposition', 'attachment; filename="modele_import_utilisateurs.csv"');
|
res.setHeader('Content-Disposition', 'attachment; filename="modele_import_utilisateurs.csv"');
|
||||||
res.send('\uFEFF' + csvContent); // BOM UTF-8 pour Excel
|
res.send('\uFEFF' + csvContent); // BOM UTF-8 pour Excel
|
||||||
@@ -232,7 +232,7 @@ router.post('/users/import', authenticate, authorize('admin'), uploadMemory.sing
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
const validRoles = ['admin', 'approbateur', 'validateur', 'operateur'];
|
const validRoles = ['admin', 'standard', 'readonly'];
|
||||||
const results = { created: 0, updated: 0, errors: [] as string[] };
|
const results = { created: 0, updated: 0, errors: [] as string[] };
|
||||||
|
|
||||||
// Parsing CSV ou Excel
|
// Parsing CSV ou Excel
|
||||||
@@ -265,7 +265,7 @@ router.post('/users/import', authenticate, authorize('admin'), uploadMemory.sing
|
|||||||
const email = String(row[0] || '').trim().toLowerCase();
|
const email = String(row[0] || '').trim().toLowerCase();
|
||||||
const prenom = String(row[1] || '').trim();
|
const prenom = String(row[1] || '').trim();
|
||||||
const nom = String(row[2] || '').trim();
|
const nom = String(row[2] || '').trim();
|
||||||
const role = String(row[3] || 'operateur').trim().toLowerCase();
|
const role = String(row[3] || 'standard').trim().toLowerCase();
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!email || !email.includes('@')) {
|
if (!email || !email.includes('@')) {
|
||||||
@@ -276,7 +276,7 @@ router.post('/users/import', authenticate, authorize('admin'), uploadMemory.sing
|
|||||||
results.errors.push(`Ligne ${lineNum} : prénom ou nom manquant`);
|
results.errors.push(`Ligne ${lineNum} : prénom ou nom manquant`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const finalRole = validRoles.includes(role) ? role : 'operateur';
|
const finalRole = validRoles.includes(role) ? role : 'standard';
|
||||||
|
|
||||||
// Vérifier si l'utilisateur existe
|
// Vérifier si l'utilisateur existe
|
||||||
const [existing]: any = await pool.execute(
|
const [existing]: any = await pool.execute(
|
||||||
|
|||||||
@@ -67,28 +67,32 @@ router.get('/', authenticate, async (req: AuthRequest, res: Response) => {
|
|||||||
'SELECT source, COUNT(*) as count FROM invoices GROUP BY source'
|
'SELECT source, COUNT(*) as count FROM invoices GROUP BY source'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Factures en attente d'action selon le rôle
|
// Factures en attente d'action selon le rôle (skill : admin / standard / readonly)
|
||||||
let pendingForUser: any[] = [];
|
let pendingForUser: any[] = [];
|
||||||
const role = req.user!.role;
|
const role = req.user!.role;
|
||||||
|
|
||||||
if (role === 'operateur' || role === 'admin') {
|
// standard et admin voient les factures reçues à vérifier
|
||||||
|
if (role === 'standard' || role === 'admin') {
|
||||||
const [rows]: any = await pool.execute(
|
const [rows]: any = await pool.execute(
|
||||||
`SELECT COUNT(*) as count FROM invoices WHERE status = 'recue'`
|
`SELECT COUNT(*) as count FROM invoices WHERE status = 'recue'`
|
||||||
);
|
);
|
||||||
pendingForUser.push({ action: 'À vérifier', count: rows[0].count });
|
pendingForUser.push({ action: 'À vérifier', count: rows[0].count });
|
||||||
}
|
}
|
||||||
if (role === 'validateur' || role === 'admin') {
|
// standard et admin voient les factures en vérification à valider
|
||||||
|
if (role === 'standard' || role === 'admin') {
|
||||||
const [rows]: any = await pool.execute(
|
const [rows]: any = await pool.execute(
|
||||||
`SELECT COUNT(*) as count FROM invoices WHERE status = 'en_verification'`
|
`SELECT COUNT(*) as count FROM invoices WHERE status = 'en_verification'`
|
||||||
);
|
);
|
||||||
pendingForUser.push({ action: 'À valider', count: rows[0].count });
|
pendingForUser.push({ action: 'À valider', count: rows[0].count });
|
||||||
}
|
}
|
||||||
if (role === 'approbateur' || role === 'admin') {
|
// standard et admin voient les factures validées à approuver
|
||||||
|
if (role === 'standard' || role === 'admin') {
|
||||||
const [rows]: any = await pool.execute(
|
const [rows]: any = await pool.execute(
|
||||||
`SELECT COUNT(*) as count FROM invoices WHERE status = 'validee'`
|
`SELECT COUNT(*) as count FROM invoices WHERE status = 'validee'`
|
||||||
);
|
);
|
||||||
pendingForUser.push({ action: 'À approuver', count: rows[0].count });
|
pendingForUser.push({ action: 'À approuver', count: rows[0].count });
|
||||||
}
|
}
|
||||||
|
// admin uniquement voit les factures approuvées à payer
|
||||||
if (role === 'admin') {
|
if (role === 'admin') {
|
||||||
const [rows]: any = await pool.execute(
|
const [rows]: any = await pool.execute(
|
||||||
`SELECT COUNT(*) as count FROM invoices WHERE status = 'approuvee'`
|
`SELECT COUNT(*) as count FROM invoices WHERE status = 'approuvee'`
|
||||||
|
|||||||
@@ -401,12 +401,14 @@ router.post('/:id/status', authenticate, async (req: AuthRequest, res: Response)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vérifier les permissions
|
// Vérifier les permissions (skill itinova-user-management : admin / standard / readonly)
|
||||||
const role = req.user!.role;
|
const role = req.user!.role;
|
||||||
const rolePermissions: Record<string, string[]> = {
|
const rolePermissions: Record<string, string[]> = {
|
||||||
'operateur': ['en_verification', 'recue'],
|
// readonly : consultation uniquement, aucune modification de statut
|
||||||
'validateur': ['en_verification', 'validee', 'rejetee', 'recue'],
|
'readonly': [],
|
||||||
'approbateur': ['en_verification', 'validee', 'approuvee', 'rejetee', 'recue'],
|
// standard : droits métiers complets (validation, approbation, rejet)
|
||||||
|
'standard': ['en_verification', 'validee', 'approuvee', 'rejetee', 'recue'],
|
||||||
|
// admin : tous les droits y compris paiement et archivage
|
||||||
'admin': ['en_verification', 'validee', 'approuvee', 'payee', 'rejetee', 'recue', 'archivee'],
|
'admin': ['en_verification', 'validee', 'approuvee', 'payee', 'rejetee', 'recue', 'archivee'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -101,13 +101,17 @@ export default function InvoiceDetail() {
|
|||||||
const role = user.role;
|
const role = user.role;
|
||||||
const s = invoice.status;
|
const s = invoice.status;
|
||||||
|
|
||||||
if (s === 'recue' && ['operateur', 'validateur', 'approbateur', 'admin'].includes(role)) {
|
// Permissions selon le skill itinova-user-management (admin / standard / readonly)
|
||||||
|
// readonly : aucune action disponible
|
||||||
|
// standard : droits métiers complets (vérification, validation, approbation, rejet)
|
||||||
|
// admin : tous les droits y compris paiement et archivage
|
||||||
|
if (s === 'recue' && ['standard', 'admin'].includes(role)) {
|
||||||
actions.push({ status: 'en_verification', label: 'Passer en vérification', color: 'btn-primary' });
|
actions.push({ status: 'en_verification', label: 'Passer en vérification', color: 'btn-primary' });
|
||||||
}
|
}
|
||||||
if (s === 'en_verification' && ['validateur', 'approbateur', 'admin'].includes(role)) {
|
if (s === 'en_verification' && ['standard', 'admin'].includes(role)) {
|
||||||
actions.push({ status: 'validee', label: 'Valider', color: 'btn-success' });
|
actions.push({ status: 'validee', label: 'Valider', color: 'btn-success' });
|
||||||
}
|
}
|
||||||
if (s === 'validee' && ['approbateur', 'admin'].includes(role)) {
|
if (s === 'validee' && ['standard', 'admin'].includes(role)) {
|
||||||
actions.push({ status: 'approuvee', label: 'Approuver', color: 'btn-success' });
|
actions.push({ status: 'approuvee', label: 'Approuver', color: 'btn-success' });
|
||||||
}
|
}
|
||||||
if (s === 'approuvee' && role === 'admin') {
|
if (s === 'approuvee' && role === 'admin') {
|
||||||
@@ -116,10 +120,10 @@ export default function InvoiceDetail() {
|
|||||||
if (s === 'payee' && role === 'admin') {
|
if (s === 'payee' && role === 'admin') {
|
||||||
actions.push({ status: 'archivee', label: 'Archiver', color: 'btn-secondary' });
|
actions.push({ status: 'archivee', label: 'Archiver', color: 'btn-secondary' });
|
||||||
}
|
}
|
||||||
if (!['payee', 'archivee', 'rejetee'].includes(s) && ['validateur', 'approbateur', 'admin'].includes(role)) {
|
if (!['payee', 'archivee', 'rejetee'].includes(s) && ['standard', 'admin'].includes(role)) {
|
||||||
actions.push({ status: 'rejetee', label: 'Rejeter', color: 'btn-danger' });
|
actions.push({ status: 'rejetee', label: 'Rejeter', color: 'btn-danger' });
|
||||||
}
|
}
|
||||||
if (s === 'rejetee' && ['operateur', 'validateur', 'approbateur', 'admin'].includes(role)) {
|
if (s === 'rejetee' && ['standard', 'admin'].includes(role)) {
|
||||||
actions.push({ status: 'recue', label: 'Remettre en réception', color: 'btn-secondary' });
|
actions.push({ status: 'recue', label: 'Remettre en réception', color: 'btn-secondary' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function UserList() {
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
email: '', password: '', firstName: '', lastName: '', role: 'operateur',
|
email: '', password: '', firstName: '', lastName: '', role: 'standard',
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => { loadUsers(); }, []);
|
useEffect(() => { loadUsers(); }, []);
|
||||||
@@ -41,7 +41,7 @@ export default function UserList() {
|
|||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditingUser(null);
|
setEditingUser(null);
|
||||||
setForm({ email: '', password: '', firstName: '', lastName: '', role: 'operateur' });
|
setForm({ email: '', password: '', firstName: '', lastName: '', role: 'standard' });
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -242,7 +242,7 @@ export default function UserList() {
|
|||||||
<p className="font-semibold mb-1">Format attendu (colonnes) :</p>
|
<p className="font-semibold mb-1">Format attendu (colonnes) :</p>
|
||||||
<code className="text-xs bg-blue-100 px-2 py-1 rounded">email, prenom, nom, role</code>
|
<code className="text-xs bg-blue-100 px-2 py-1 rounded">email, prenom, nom, role</code>
|
||||||
<p className="mt-2 text-xs">
|
<p className="mt-2 text-xs">
|
||||||
Roles : <strong>admin</strong>, <strong>approbateur</strong>, <strong>validateur</strong>, <strong>operateur</strong> (défaut si vide).
|
Profils : <strong>admin</strong>, <strong>standard</strong>, <strong>readonly</strong> (défaut : <strong>standard</strong> si vide).
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs mt-1">
|
<p className="text-xs mt-1">
|
||||||
Utilisateurs existants (même email) : mis à jour. Nouveaux : mot de passe temporaire généré.
|
Utilisateurs existants (même email) : mis à jour. Nouveaux : mot de passe temporaire généré.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
role: 'admin' | 'approbateur' | 'validateur' | 'operateur';
|
role: 'admin' | 'standard' | 'readonly';
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
|
|||||||
@@ -83,9 +83,8 @@ export const poStatusColors: Record<string, string> = {
|
|||||||
|
|
||||||
export const roleLabels: Record<string, string> = {
|
export const roleLabels: Record<string, string> = {
|
||||||
admin: 'Administrateur',
|
admin: 'Administrateur',
|
||||||
approbateur: 'Approbateur',
|
standard: 'Standard',
|
||||||
validateur: 'Validateur',
|
readonly: 'Lecture seule',
|
||||||
operateur: 'Opérateur',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function isOverdue(dueDate: string | undefined | null, status: string): boolean {
|
export function isOverdue(dueDate: string | undefined | null, status: string): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user