mirror of
https://github.com/R0m1k3/Presencia.git
synced 2026-10-24 15:48:03 +02:00
Revue complète : sécurité, optimisations et UI/UX mobile
Sécurité - JWT : un JWT_SECRET d'exemple ou trop court est ignoré au profit d'un secret aléatoire conservé en base (avant, la valeur publique de docker-compose.yml permettait de forger un jeton admin). - Le compte est relu en base à chaque requête : désactivation, changement de rôle et réinitialisation du mot de passe prennent effet immédiatement. - Connexion : 10 échecs max par e-mail / 15 min, temps constant que l'e-mail existe ou non, mot de passe admin retiré des logs. - Validation des entrées (ids, dates, mois, longueurs, e-mail) : 400 au lieu de 500. Un admin ne peut plus se désactiver ni se rétrograder. - Mots de passe : 8 caractères minimum ; changement en libre-service. - Export : nom de fichier conforme RFC 5987 (un nom de société avec « — » faisait planter l'export). - esc() échappe aussi les guillemets (injection d'attributs HTML). - En-têtes CSP / X-Frame-Options / nosniff (Nginx + API), API et Postgres publiés sur 127.0.0.1 seulement, image backend non-root via npm ci. Optimisations - Export : une requête pour tout le mois au lieu d'une par cadre. - Saisie groupée : un seul INSERT (unnest), doublons dédupliqués. - Dates renvoyées en chaînes (plus de décalage d'un jour selon le TZ). - Planning : chaque clic met à jour l'affichage localement au lieu de recharger le mois ; les réponses de mois périmées sont ignorées. - Recherche Utilisateurs / Plannings filtrée localement (plus une requête par touche) ; statuts des sociétés chargés en parallèle. UI/UX - Mobile : toutes les destinations dans la barre basse (Sociétés et Ma saisie étaient inaccessibles à l'admin) ; tableaux affichés en cartes (les boutons d'action étaient hors écran). - Boutons désactivés pendant l'envoi, focus et Échap dans les dialogues, retour à l'écran de connexion quand la session est révoquée, erreurs d'export affichées au lieu d'un fichier JSON téléchargé, actions destructives signalées, confirmation avant désactivation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D5Bdayziw6tybgqETZoNSt
This commit is contained in:
22 files changed
+830
-415
No files matched your search
@@ -3,9 +3,11 @@ const db = require('../db');
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
const { buildCompanyWorkbook } = require('../utils/excel');
|
||||
const { buildCompanyPdf } = require('../utils/pdf');
|
||||
const { parseYearMonth, monthStart, idParam } = require('../utils/validate');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireAdmin);
|
||||
router.param('companyId', idParam);
|
||||
|
||||
async function loadCompanyData(companyId, year, month) {
|
||||
const { rows: companyRows } = await db.query('SELECT id, name FROM companies WHERE id = $1', [companyId]);
|
||||
@@ -17,28 +19,37 @@ async function loadCompanyData(companyId, year, month) {
|
||||
[companyId]
|
||||
);
|
||||
|
||||
const start = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const cadres = [];
|
||||
for (const c of cadreRows) {
|
||||
const { rows: entries } = await db.query(
|
||||
`SELECT entry_date, period, status FROM attendance_entries
|
||||
WHERE user_id = $1 AND entry_date >= $2::date AND entry_date < ($2::date + INTERVAL '1 month')`,
|
||||
[c.id, start]
|
||||
);
|
||||
const map = new Map();
|
||||
for (const e of entries) {
|
||||
map.set(`${e.entry_date.toISOString().slice(0, 10)}:${e.period}`, e.status);
|
||||
}
|
||||
cadres.push({ id: c.id, fullName: c.full_name, entries: map });
|
||||
// All entries of the month in one query, rather than one query per cadre.
|
||||
const { rows: entryRows } = await db.query(
|
||||
`SELECT ae.user_id, ae.entry_date, ae.period, ae.status
|
||||
FROM attendance_entries ae
|
||||
JOIN users u ON u.id = ae.user_id
|
||||
WHERE u.company_id = $1 AND u.role = 'cadre' AND u.active = true
|
||||
AND ae.entry_date >= $2::date AND ae.entry_date < ($2::date + INTERVAL '1 month')`,
|
||||
[companyId, monthStart({ year, month })]
|
||||
);
|
||||
const cadres = cadreRows.map((c) => ({ id: c.id, fullName: c.full_name, entries: new Map() }));
|
||||
const byId = new Map(cadres.map((c) => [c.id, c]));
|
||||
for (const e of entryRows) {
|
||||
byId.get(e.user_id).entries.set(`${e.entry_date}:${e.period}`, e.status);
|
||||
}
|
||||
|
||||
return { company, cadres };
|
||||
}
|
||||
|
||||
// Content-Disposition value. Header values must be Latin-1, so a company
|
||||
// name like « Société — Nord » would make Node throw: send an ASCII fallback
|
||||
// plus the exact UTF-8 name (RFC 6266 / 5987).
|
||||
function attachment(companyName, year, month, ext) {
|
||||
const base = `presences_${companyName}_${year}-${String(month).padStart(2, '0')}.${ext}`.replace(/\s+/g, '_');
|
||||
const ascii = base.normalize('NFD').replace(/[^\x20-\x7e]/g, '').replace(/["\\/;]/g, '_');
|
||||
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(base.replace(/[\\/]/g, '_')).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`;
|
||||
}
|
||||
|
||||
router.get('/excel/:companyId', async (req, res) => {
|
||||
const year = parseInt(req.query.year, 10);
|
||||
const month = parseInt(req.query.month, 10);
|
||||
if (!year || !month) return res.status(400).json({ error: 'year et month requis' });
|
||||
const ym = parseYearMonth(req.query.year, req.query.month);
|
||||
if (!ym) return res.status(400).json({ error: 'year et month requis' });
|
||||
const { year, month } = ym;
|
||||
|
||||
const data = await loadCompanyData(req.params.companyId, year, month);
|
||||
if (!data) return res.status(404).json({ error: 'Société introuvable' });
|
||||
@@ -50,24 +61,22 @@ router.get('/excel/:companyId', async (req, res) => {
|
||||
cadres: data.cadres,
|
||||
});
|
||||
|
||||
const filename = `presences_${data.company.name.replace(/\s+/g, '_')}_${year}-${String(month).padStart(2, '0')}.xlsx`;
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Content-Disposition', attachment(data.company.name, year, month, 'xlsx'));
|
||||
await workbook.xlsx.write(res);
|
||||
res.end();
|
||||
});
|
||||
|
||||
router.get('/pdf/:companyId', async (req, res) => {
|
||||
const year = parseInt(req.query.year, 10);
|
||||
const month = parseInt(req.query.month, 10);
|
||||
if (!year || !month) return res.status(400).json({ error: 'year et month requis' });
|
||||
const ym = parseYearMonth(req.query.year, req.query.month);
|
||||
if (!ym) return res.status(400).json({ error: 'year et month requis' });
|
||||
const { year, month } = ym;
|
||||
|
||||
const data = await loadCompanyData(req.params.companyId, year, month);
|
||||
if (!data) return res.status(404).json({ error: 'Société introuvable' });
|
||||
|
||||
const filename = `presences_${data.company.name.replace(/\s+/g, '_')}_${year}-${String(month).padStart(2, '0')}.pdf`;
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Content-Disposition', attachment(data.company.name, year, month, 'pdf'));
|
||||
|
||||
const doc = buildCompanyPdf({
|
||||
companyName: data.company.name,
|
||||
|
||||
Reference in new issue
Block a user