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:
Claude committed 2026-10-09 07:17:54 +00:00
1 parent da875afb73
commit 3df521ac30
22 files changed
+830 -415

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
node_modules
npm-debug.log
.env
+7 -2
View File
@@ -2,12 +2,17 @@ FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
# Install from the lockfile so the image gets exactly the tested versions.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY . .
ENV NODE_ENV=production
ENV PORT=4790
EXPOSE 4790
# Run as the unprivileged user shipped with the node image, not root.
USER node
CMD ["node", "src/index.js"]
+10
View File
@@ -0,0 +1,10 @@
-- Idempotent: run on every start, after 001_init.sql.
-- Server-side settings, e.g. the generated JWT signing secret.
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Sessions issued before this instant are rejected (password change).
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ;
+13 -7
View File
@@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path');
const bcrypt = require('bcryptjs');
const db = require('./db');
const { initJwtSecret } = require('./middleware/auth');
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -32,13 +33,17 @@ async function runMigrations() {
);
if (rows[0].exists) {
console.log('Schéma déjà initialisé.');
return;
} else {
console.log('Initialisation du schéma de base de données...');
await db.query(readMigration('001_init.sql'));
console.log('Schéma créé.');
}
const sqlPath = path.join(__dirname, '..', 'migrations', '001_init.sql');
const sql = fs.readFileSync(sqlPath, 'utf8');
console.log('Initialisation du schéma de base de données...');
await db.query(sql);
console.log('Schéma créé.');
// Idempotent, so safe on every start: brings existing databases up to date.
await db.query(readMigration('002_security.sql'));
}
function readMigration(name) {
return fs.readFileSync(path.join(__dirname, '..', 'migrations', name), 'utf8');
}
async function seedAdmin() {
@@ -60,7 +65,7 @@ async function seedAdmin() {
console.log('========================================================');
console.log(' Compte administrateur créé :');
console.log(` Email : ${email}`);
console.log(` Mot de passe : ${password}`);
console.log(' Mot de passe : celui de ADMIN_PASSWORD');
console.log(' Merci de le changer après la première connexion.');
console.log('========================================================');
}
@@ -68,6 +73,7 @@ async function seedAdmin() {
async function bootstrap() {
await waitForDb();
await runMigrations();
await initJwtSecret();
await seedAdmin();
}
+6 -1
View File
@@ -1,4 +1,9 @@
const { Pool } = require('pg');
const { Pool, types } = require('pg');
// Return DATE columns as 'YYYY-MM-DD' strings. By default pg builds a JS Date
// at local midnight, and toISOString() then shifts it to the previous day as
// soon as the server runs in a timezone ahead of UTC (e.g. TZ=Europe/Paris).
types.setTypeParser(types.builtins.DATE, (v) => v);
const pool = new Pool({
host: process.env.PGHOST || 'db',
+26 -1
View File
@@ -15,7 +15,18 @@ const exportRoutes = require('./routes/export');
const app = express();
const PORT = process.env.PORT || 4790;
app.use(express.json());
app.disable('x-powered-by');
// The API is also reachable on its own port, without the nginx headers.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'same-origin',
'Cache-Control': 'no-store',
});
next();
});
app.use(express.json({ limit: '100kb' }));
app.use(cookieParser());
if (process.env.CORS_ORIGIN) {
app.use(cors({ origin: process.env.CORS_ORIGIN, credentials: true }));
@@ -30,7 +41,21 @@ app.use('/api/attendance', attendanceRoutes);
app.use('/api/validations', validationRoutes);
app.use('/api/export', exportRoutes);
app.use('/api', (req, res) => res.status(404).json({ error: 'Route inconnue' }));
// Postgres errors caused by the request content rather than by the server.
const CLIENT_PG_ERRORS = {
'22P02': 'Valeur invalide', // invalid_text_representation
'22007': 'Date invalide', // invalid_datetime_format
'22008': 'Date invalide', // datetime_field_overflow
'22001': 'Valeur trop longue', // string_data_right_truncation
'23503': 'Élément référencé introuvable', // foreign_key_violation
};
app.use((err, req, res, next) => {
if (err.type === 'entity.parse.failed') return res.status(400).json({ error: 'Corps de requête JSON invalide' });
if (err.type === 'entity.too.large') return res.status(413).json({ error: 'Requête trop volumineuse' });
if (CLIENT_PG_ERRORS[err.code]) return res.status(400).json({ error: CLIENT_PG_ERRORS[err.code] });
console.error(err);
res.status(500).json({ error: 'Erreur interne du serveur' });
});
+60 -16
View File
@@ -1,20 +1,40 @@
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const db = require('../db');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
const COOKIE_NAME = 'presencia_token';
function signToken(user) {
return jwt.sign(
{
id: user.id,
role: user.role,
companyId: user.company_id,
fullName: user.full_name,
email: user.email,
},
JWT_SECRET,
{ expiresIn: '12h' }
// Values that have shipped in this repository or its docs. Anyone can read
// them, so a token signed with one of them can be forged by anyone.
const KNOWN_PLACEHOLDERS = new Set([
'change-me-to-a-long-random-string',
'dev-secret-change-me',
]);
let jwtSecret = null;
// Called once at startup. Uses JWT_SECRET when it is a real secret; otherwise
// generates one and keeps it in the database so sessions survive restarts.
async function initJwtSecret() {
const fromEnv = process.env.JWT_SECRET;
if (fromEnv && fromEnv.length >= 32 && !KNOWN_PLACEHOLDERS.has(fromEnv)) {
jwtSecret = fromEnv;
return;
}
if (fromEnv) {
console.warn('JWT_SECRET ignoré (trop court ou valeur d’exemple) : un secret aléatoire est utilisé à la place.');
}
const candidate = crypto.randomBytes(48).toString('base64url');
await db.query(
"INSERT INTO app_settings (key, value) VALUES ('jwt_secret', $1) ON CONFLICT (key) DO NOTHING",
[candidate]
);
const { rows } = await db.query("SELECT value FROM app_settings WHERE key = 'jwt_secret'");
jwtSecret = rows[0].value;
}
function signToken(user) {
return jwt.sign({ id: user.id }, jwtSecret, { expiresIn: '12h' });
}
function setAuthCookie(res, token) {
@@ -31,16 +51,39 @@ function clearAuthCookie(res) {
res.clearCookie(COOKIE_NAME, { path: '/' });
}
function requireAuth(req, res, next) {
// The token only proves who the caller is. Role, company and whether the
// account is still active are read from the database on every request, so
// deactivating or demoting an account takes effect immediately rather than
// when its 12-hour token expires.
async function requireAuth(req, res, next) {
const token = req.cookies && req.cookies[COOKIE_NAME];
if (!token) return res.status(401).json({ error: 'Non authentifié' });
let payload;
try {
const payload = jwt.verify(token, JWT_SECRET);
req.user = payload;
next();
payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] });
} catch (err) {
clearAuthCookie(res);
return res.status(401).json({ error: 'Session invalide ou expirée' });
}
const { rows } = await db.query(
`SELECT id, full_name, email, role, company_id, active, password_changed_at
FROM users WHERE id = $1`,
[payload.id]
);
const u = rows[0];
const changedAt = u && u.password_changed_at ? Math.floor(u.password_changed_at.getTime() / 1000) : 0;
if (!u || !u.active || payload.iat < changedAt) {
clearAuthCookie(res);
return res.status(401).json({ error: 'Session invalide ou expirée' });
}
req.user = {
id: u.id,
role: u.role,
companyId: u.company_id,
fullName: u.full_name,
email: u.email,
};
next();
}
function requireAdmin(req, res, next) {
@@ -51,6 +94,7 @@ function requireAdmin(req, res, next) {
}
module.exports = {
initJwtSecret,
signToken,
setAuthCookie,
clearAuthCookie,
+113 -166
View File
@@ -1,6 +1,7 @@
const express = require('express');
const db = require('../db');
const { requireAuth } = require('../middleware/auth');
const { isId, isDate, parseYearMonth, monthStart } = require('../utils/validate');
const router = express.Router();
router.use(requireAuth);
@@ -8,103 +9,105 @@ router.use(requireAuth);
const STATUSES = ['present', 'absent', 'conge', 'rtt'];
const PERIODS = ['AM', 'PM'];
async function getTargetUser(req, requestedUserId) {
// Cadre can only ever act on themselves. Admin may act on any user.
if (req.user.role === 'admin' && requestedUserId) {
const { rows } = await db.query(
'SELECT id, role, company_id FROM users WHERE id = $1',
[requestedUserId]
);
return rows[0] || null;
// Resolves whose planning a request acts on. A cadre can only ever act on
// themselves; an admin may pass user_id to act on anyone. Sends the error
// response and returns null when the request is not allowed.
async function resolveTarget(req, res, requestedUserId) {
if (requestedUserId === undefined || requestedUserId === null || requestedUserId === '') {
return { id: req.user.id, company_id: req.user.companyId };
}
return { id: req.user.id, role: req.user.role, company_id: req.user.companyId };
if (req.user.role !== 'admin') {
res.status(403).json({ error: 'Accès refusé' });
return null;
}
if (!isId(String(requestedUserId))) {
res.status(400).json({ error: 'Utilisateur invalide' });
return null;
}
const { rows } = await db.query('SELECT id, company_id FROM users WHERE id = $1', [requestedUserId]);
if (!rows[0]) {
res.status(404).json({ error: 'Utilisateur introuvable' });
return null;
}
return rows[0];
}
async function getMonthLock(userId, year, month) {
async function monthState(target, { year, month }) {
const { rows } = await db.query(
'SELECT cadre_validated, cadre_validated_at FROM month_locks WHERE user_id = $1 AND year = $2 AND month = $3',
[userId, year, month]
`SELECT
(SELECT row_to_json(ml) FROM (
SELECT cadre_validated, cadre_validated_at FROM month_locks
WHERE user_id = $1 AND year = $3 AND month = $4) ml) AS lock,
(SELECT row_to_json(cv) FROM (
SELECT admin_validated, admin_validated_at FROM company_month_validations
WHERE company_id = $2 AND year = $3 AND month = $4) cv) AS company`,
[target.id, target.company_id, year, month]
);
return rows[0] || { cadre_validated: false, cadre_validated_at: null };
const lock = rows[0].lock || {};
const company = rows[0].company || {};
return {
cadreValidated: !!lock.cadre_validated,
cadreValidatedAt: lock.cadre_validated_at || null,
companyValidated: !!company.admin_validated,
companyValidatedAt: company.admin_validated_at || null,
};
}
async function getCompanyValidation(companyId, year, month) {
if (!companyId) return { admin_validated: false, admin_validated_at: null };
const { rows } = await db.query(
'SELECT admin_validated, admin_validated_at FROM company_month_validations WHERE company_id = $1 AND year = $2 AND month = $3',
[companyId, year, month]
);
return rows[0] || { admin_validated: false, admin_validated_at: null };
// Same lock rules for every write: nobody edits a month the admin validated
// for the company; a cadre cannot edit a month they validated themselves.
// Sends a 423 and returns false when the month is locked.
async function ensureWritable(req, res, target, ym) {
const st = await monthState(target, ym);
if (st.companyValidated) {
res.status(423).json({ error: 'Ce mois a été validé par l’administrateur et est verrouillé' });
return false;
}
if (req.user.role !== 'admin' && st.cadreValidated) {
res.status(423).json({ error: 'Vous avez déjà validé ce mois. Contactez un administrateur pour le modifier.' });
return false;
}
return true;
}
const ymOf = (date) => ({ year: Number(date.slice(0, 4)), month: Number(date.slice(5, 7)) });
router.get('/', 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 target = await resolveTarget(req, res, req.query.user_id);
if (!target) return;
const requestedUserId = req.query.user_id;
if (requestedUserId && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Accès refusé' });
}
const target = await getTargetUser(req, requestedUserId);
if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' });
const start = `${year}-${String(month).padStart(2, '0')}-01`;
const { rows } = 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')
ORDER BY entry_date, period`,
[target.id, start]
);
const lock = await getMonthLock(target.id, year, month);
const companyValidation = await getCompanyValidation(target.company_id, year, month);
const [{ rows }, st] = await Promise.all([
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')
ORDER BY entry_date, period`,
[target.id, monthStart(ym)]
),
monthState(target, ym),
]);
res.json({
userId: target.id,
entries: rows.map((r) => ({
date: r.entry_date.toISOString().slice(0, 10),
period: r.period,
status: r.status,
})),
cadreValidated: lock.cadre_validated,
cadreValidatedAt: lock.cadre_validated_at,
companyValidated: companyValidation.admin_validated,
companyValidatedAt: companyValidation.admin_validated_at,
editable:
req.user.role === 'admin'
? !companyValidation.admin_validated
: !lock.cadre_validated && !companyValidation.admin_validated,
entries: rows.map((r) => ({ date: r.entry_date, period: r.period, status: r.status })),
...st,
editable: req.user.role === 'admin'
? !st.companyValidated
: !st.cadreValidated && !st.companyValidated,
});
});
router.put('/', async (req, res) => {
const { date, period, status, user_id: requestedUserId } = req.body || {};
if (!date || !PERIODS.includes(period) || !STATUSES.includes(status)) {
if (!isDate(date) || !PERIODS.includes(period) || !STATUSES.includes(status)) {
return res.status(400).json({ error: 'Paramètres invalides' });
}
if (requestedUserId && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Accès refusé' });
}
const target = await getTargetUser(req, requestedUserId);
if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' });
const d = new Date(date + 'T00:00:00Z');
const year = d.getUTCFullYear();
const month = d.getUTCMonth() + 1;
const lock = await getMonthLock(target.id, year, month);
const companyValidation = await getCompanyValidation(target.company_id, year, month);
if (companyValidation.admin_validated) {
return res.status(423).json({ error: 'Ce mois a été validé par l’administrateur et est verrouillé' });
}
if (req.user.role !== 'admin' && lock.cadre_validated) {
return res.status(423).json({ error: 'Vous avez déjà validé ce mois. Contactez un administrateur pour le modifier.' });
}
const target = await resolveTarget(req, res, requestedUserId);
if (!target) return;
if (!(await ensureWritable(req, res, target, ymOf(date)))) return;
await db.query(
`INSERT INTO attendance_entries (user_id, entry_date, period, status, updated_at)
@@ -113,19 +116,14 @@ router.put('/', async (req, res) => {
DO UPDATE SET status = EXCLUDED.status, updated_at = now()`,
[target.id, date, period, status]
);
res.json({ ok: true });
});
// Per-month aggregates for the history view: half-day counts by status plus
// both validation flags, most recent month first.
router.get('/history', async (req, res) => {
const requestedUserId = req.query.user_id;
if (requestedUserId && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Accès refusé' });
}
const target = await getTargetUser(req, requestedUserId);
if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' });
const target = await resolveTarget(req, res, req.query.user_id);
if (!target) return;
const { rows } = await db.query(
`WITH months AS (
@@ -163,115 +161,64 @@ router.get('/history', async (req, res) => {
})));
});
// Clear every entry of a month (the « Tout effacer » action). Same lock rules
// as writes.
// Clear every entry of a month (the « Tout effacer » action).
router.delete('/month', async (req, res) => {
const { year, month, user_id: requestedUserId } = req.body || {};
const y = parseInt(year, 10);
const m = parseInt(month, 10);
if (!y || !m || m < 1 || m > 12) return res.status(400).json({ error: 'Paramètres invalides' });
if (requestedUserId && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Accès refusé' });
}
const target = await getTargetUser(req, requestedUserId);
if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' });
const ym = parseYearMonth(year, month);
if (!ym) return res.status(400).json({ error: 'Paramètres invalides' });
const target = await resolveTarget(req, res, requestedUserId);
if (!target) return;
if (!(await ensureWritable(req, res, target, ym))) return;
const lock = await getMonthLock(target.id, y, m);
const companyValidation = await getCompanyValidation(target.company_id, y, m);
if (companyValidation.admin_validated) {
return res.status(423).json({ error: 'Ce mois a été validé par l’administrateur et est verrouillé' });
}
if (req.user.role !== 'admin' && lock.cadre_validated) {
return res.status(423).json({ error: 'Vous avez déjà validé ce mois. Contactez un administrateur pour le modifier.' });
}
const start = `${y}-${String(m).padStart(2, '0')}-01`;
await db.query(
`DELETE FROM attendance_entries
WHERE user_id = $1 AND entry_date >= $2::date AND entry_date < ($2::date + INTERVAL '1 month')`,
[target.id, start]
[target.id, monthStart(ym)]
);
res.json({ ok: true });
});
// Bulk upsert (e.g. « fill all empty weekdays with présent »). All entries
// must pass the same lock checks as single writes; months are checked once
// per distinct month present in the payload.
// Bulk upsert (e.g. « fill all empty weekdays with présent »). Every month
// present in the payload must pass the same lock checks as single writes.
router.put('/bulk', async (req, res) => {
const { entries, user_id: requestedUserId } = req.body || {};
if (!Array.isArray(entries) || entries.length === 0 || entries.length > 200) {
return res.status(400).json({ error: 'Paramètres invalides' });
}
for (const e of entries) {
if (!e || !e.date || !PERIODS.includes(e.period) || !STATUSES.includes(e.status)) {
return res.status(400).json({ error: 'Paramètres invalides' });
}
if (entries.some((e) => !e || !isDate(e.date) || !PERIODS.includes(e.period) || !STATUSES.includes(e.status))) {
return res.status(400).json({ error: 'Paramètres invalides' });
}
if (requestedUserId && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Accès refusé' });
}
const target = await getTargetUser(req, requestedUserId);
if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' });
const target = await resolveTarget(req, res, requestedUserId);
if (!target) return;
const months = new Set(entries.map((e) => e.date.slice(0, 7)));
for (const ym of months) {
const [year, month] = ym.split('-').map(Number);
const lock = await getMonthLock(target.id, year, month);
const companyValidation = await getCompanyValidation(target.company_id, year, month);
if (companyValidation.admin_validated) {
return res.status(423).json({ error: 'Ce mois a été validé par l’administrateur et est verrouillé' });
}
if (req.user.role !== 'admin' && lock.cadre_validated) {
return res.status(423).json({ error: 'Vous avez déjà validé ce mois. Contactez un administrateur pour le modifier.' });
}
for (const ym of new Set(entries.map((e) => e.date.slice(0, 7)))) {
if (!(await ensureWritable(req, res, target, ymOf(ym)))) return;
}
const client = await db.pool.connect();
try {
await client.query('BEGIN');
for (const e of entries) {
await client.query(
`INSERT INTO attendance_entries (user_id, entry_date, period, status, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (user_id, entry_date, period)
DO UPDATE SET status = EXCLUDED.status, updated_at = now()`,
[target.id, e.date, e.period, e.status]
);
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
// One statement for the whole batch. A half-day listed twice would make
// ON CONFLICT hit the same row twice and fail, so the last one wins.
const byKey = new Map(entries.map((e) => [`${e.date}|${e.period}`, e]));
const list = [...byKey.values()];
await db.query(
`INSERT INTO attendance_entries (user_id, entry_date, period, status, updated_at)
SELECT $1, d, p::half_day_period, s::attendance_status, now()
FROM unnest($2::date[], $3::text[], $4::text[]) AS t(d, p, s)
ON CONFLICT (user_id, entry_date, period)
DO UPDATE SET status = EXCLUDED.status, updated_at = now()`,
[target.id, list.map((e) => e.date), list.map((e) => e.period), list.map((e) => e.status)]
);
res.json({ ok: true, count: entries.length });
res.json({ ok: true, count: list.length });
});
router.delete('/', async (req, res) => {
const { date, period, user_id: requestedUserId } = req.body || {};
if (!date || !PERIODS.includes(period)) {
if (!isDate(date) || !PERIODS.includes(period)) {
return res.status(400).json({ error: 'Paramètres invalides' });
}
if (requestedUserId && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Accès refusé' });
}
const target = await getTargetUser(req, requestedUserId);
if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' });
const d = new Date(date + 'T00:00:00Z');
const year = d.getUTCFullYear();
const month = d.getUTCMonth() + 1;
const lock = await getMonthLock(target.id, year, month);
const companyValidation = await getCompanyValidation(target.company_id, year, month);
if (companyValidation.admin_validated) {
return res.status(423).json({ error: 'Ce mois a été validé par l’administrateur et est verrouillé' });
}
if (req.user.role !== 'admin' && lock.cadre_validated) {
return res.status(423).json({ error: 'Vous avez déjà validé ce mois. Contactez un administrateur pour le modifier.' });
}
const target = await resolveTarget(req, res, requestedUserId);
if (!target) return;
if (!(await ensureWritable(req, res, target, ymOf(date)))) return;
await db.query(
'DELETE FROM attendance_entries WHERE user_id = $1 AND entry_date = $2 AND period = $3',
+82 -28
View File
@@ -2,40 +2,81 @@ const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db');
const { signToken, setAuthCookie, clearAuthCookie, requireAuth } = require('../middleware/auth');
const { passwordError } = require('../utils/validate');
const router = express.Router();
// Brute-force guard: after MAX_FAILURES wrong passwords for one email within
// WINDOW_MS, further attempts on that email are refused until the window ends.
const MAX_FAILURES = 10;
const WINDOW_MS = 15 * 60 * 1000;
const failures = new Map(); // email -> { count, since }
function lockedFor(email) {
const f = failures.get(email);
if (!f) return 0;
const left = f.since + WINDOW_MS - Date.now();
if (left <= 0) {
failures.delete(email);
return 0;
}
return f.count >= MAX_FAILURES ? left : 0;
}
function recordFailure(email) {
const now = Date.now();
if (failures.size > 10000) {
for (const [k, f] of failures) if (f.since + WINDOW_MS <= now) failures.delete(k);
}
const f = failures.get(email);
if (!f || f.since + WINDOW_MS <= now) failures.set(email, { count: 1, since: now });
else f.count += 1;
}
// Compared against when the email is unknown, so that a wrong email and a
// wrong password take the same time and do not reveal which accounts exist.
const DUMMY_HASH = bcrypt.hashSync('presencia-dummy-password', 10);
function publicUser(u) {
return {
id: u.id,
fullName: u.full_name,
email: u.email,
role: u.role,
companyId: u.company_id,
companyName: u.company_name,
};
}
router.post('/login', async (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) {
if (typeof email !== 'string' || typeof password !== 'string' || !email || !password) {
return res.status(400).json({ error: 'Email et mot de passe requis' });
}
const key = email.trim().toLowerCase();
const wait = lockedFor(key);
if (wait) {
const minutes = Math.ceil(wait / 60000);
return res.status(429).json({ error: `Trop de tentatives. Réessayez dans ${minutes} min.` });
}
const { rows } = await db.query(
`SELECT u.id, u.full_name, u.email, u.password_hash, u.role, u.active,
u.company_id, c.name AS company_name
FROM users u
LEFT JOIN companies c ON c.id = u.company_id
WHERE lower(u.email) = lower($1)`,
[email]
WHERE lower(u.email) = $1`,
[key]
);
const user = rows[0];
if (!user || !user.active) {
const ok = await bcrypt.compare(password, user ? user.password_hash : DUMMY_HASH);
if (!user || !ok || !user.active) {
recordFailure(key);
return res.status(401).json({ error: 'Identifiants incorrects' });
}
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) {
return res.status(401).json({ error: 'Identifiants incorrects' });
}
const token = signToken(user);
setAuthCookie(res, token);
res.json({
id: user.id,
fullName: user.full_name,
email: user.email,
role: user.role,
companyId: user.company_id,
companyName: user.company_name,
});
failures.delete(key);
setAuthCookie(res, signToken(user));
res.json(publicUser(user));
});
router.post('/logout', (req, res) => {
@@ -51,16 +92,29 @@ router.get('/me', requireAuth, async (req, res) => {
WHERE u.id = $1`,
[req.user.id]
);
if (!rows[0]) return res.status(401).json({ error: 'Non authentifié' });
const u = rows[0];
res.json({
id: u.id,
fullName: u.full_name,
email: u.email,
role: u.role,
companyId: u.company_id,
companyName: u.company_name,
});
res.json(publicUser(rows[0]));
});
// Any signed-in user changes their own password; the current one is required.
router.put('/password', requireAuth, async (req, res) => {
const { current, password } = req.body || {};
const err = passwordError(password);
if (err) return res.status(400).json({ error: err });
if (typeof current !== 'string' || !current) {
return res.status(400).json({ error: 'Mot de passe actuel requis' });
}
const { rows } = await db.query('SELECT password_hash FROM users WHERE id = $1', [req.user.id]);
if (!(await bcrypt.compare(current, rows[0].password_hash))) {
return res.status(400).json({ error: 'Mot de passe actuel incorrect' });
}
const hash = await bcrypt.hash(password, 10);
await db.query(
'UPDATE users SET password_hash = $1, password_changed_at = now() WHERE id = $2',
[hash, req.user.id]
);
// Other sessions are now invalid; keep this one alive with a fresh token.
setAuthCookie(res, signToken(req.user));
res.json({ ok: true });
});
module.exports = router;
+4 -2
View File
@@ -1,9 +1,11 @@
const express = require('express');
const db = require('../db');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const { isName, idParam } = require('../utils/validate');
const router = express.Router();
router.use(requireAuth, requireAdmin);
router.param('id', idParam);
router.get('/', async (req, res) => {
const { rows } = await db.query(
@@ -19,7 +21,7 @@ router.get('/', async (req, res) => {
router.post('/', async (req, res) => {
const { name } = req.body || {};
if (!name || !name.trim()) return res.status(400).json({ error: 'Nom requis' });
if (!isName(name)) return res.status(400).json({ error: 'Nom requis (255 caractères maximum)' });
try {
const { rows } = await db.query(
'INSERT INTO companies (name) VALUES ($1) RETURNING id, name, created_at',
@@ -34,7 +36,7 @@ router.post('/', async (req, res) => {
router.put('/:id', async (req, res) => {
const { name } = req.body || {};
if (!name || !name.trim()) return res.status(400).json({ error: 'Nom requis' });
if (!isName(name)) return res.status(400).json({ error: 'Nom requis (255 caractères maximum)' });
try {
const { rows } = await db.query(
'UPDATE companies SET name = $1 WHERE id = $2 RETURNING id, name, created_at',
+32 -23
View File
@@ -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,
+60 -53
View File
@@ -1,109 +1,116 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const db = require('../db');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const { requireAuth, requireAdmin, signToken, setAuthCookie } = require('../middleware/auth');
const { isId, isEmail, isName, passwordError, idParam } = require('../utils/validate');
const router = express.Router();
router.use(requireAuth, requireAdmin);
router.param('id', idParam);
const USER_COLUMNS = 'id, full_name, email, role, company_id, active, created_at';
// Shared checks for create and update. Returns an error message or null.
function profileError({ full_name, email, role, company_id }) {
if (!isName(full_name) || !email || !role) return 'Champs requis manquants';
if (!isEmail(String(email).trim())) return 'Adresse e-mail invalide';
if (!['admin', 'cadre'].includes(role)) return 'Rôle invalide';
if (role === 'cadre' && !isId(String(company_id ?? ''))) return 'Une société doit être attribuée au cadre';
return null;
}
function dbError(err, res) {
if (err.code === '23505') return res.status(409).json({ error: 'Cet email existe déjà' });
if (err.code === '23503') return res.status(400).json({ error: 'Société introuvable' });
throw err;
}
router.get('/', async (req, res) => {
const { company_id } = req.query;
const params = [];
let where = '';
if (company_id) {
params.push(company_id);
where = `WHERE u.company_id = $${params.length}`;
}
if (company_id && !isId(company_id)) return res.status(400).json({ error: 'Société invalide' });
const { rows } = await db.query(
`SELECT u.id, u.full_name, u.email, u.role, u.company_id, u.active, u.created_at,
c.name AS company_name
FROM users u
LEFT JOIN companies c ON c.id = u.company_id
${where}
${company_id ? 'WHERE u.company_id = $1' : ''}
ORDER BY u.full_name`,
params
company_id ? [company_id] : []
);
res.json(rows);
});
router.post('/', async (req, res) => {
const { full_name, email, password, role, company_id } = req.body || {};
if (!full_name || !email || !password || !role) {
return res.status(400).json({ error: 'Champs requis manquants' });
}
if (!['admin', 'cadre'].includes(role)) {
return res.status(400).json({ error: 'Rôle invalide' });
}
if (role === 'cadre' && !company_id) {
return res.status(400).json({ error: 'Une société doit être attribuée au cadre' });
}
const body = req.body || {};
const err = profileError(body) || passwordError(body.password);
if (err) return res.status(400).json({ error: err });
const { full_name, email, password, role, company_id } = body;
try {
const hash = await bcrypt.hash(password, 10);
const { rows } = await db.query(
`INSERT INTO users (full_name, email, password_hash, role, company_id, active)
VALUES ($1, $2, $3, $4, $5, true)
RETURNING id, full_name, email, role, company_id, active, created_at`,
RETURNING ${USER_COLUMNS}`,
[full_name.trim(), email.trim().toLowerCase(), hash, role, role === 'admin' ? null : company_id]
);
res.status(201).json(rows[0]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Cet email existe déjà' });
throw err;
} catch (e) {
dbError(e, res);
}
});
router.put('/:id', async (req, res) => {
const { full_name, email, role, company_id, active } = req.body || {};
if (!full_name || !email || !role) {
return res.status(400).json({ error: 'Champs requis manquants' });
}
if (!['admin', 'cadre'].includes(role)) {
return res.status(400).json({ error: 'Rôle invalide' });
}
if (role === 'cadre' && !company_id) {
return res.status(400).json({ error: 'Une société doit être attribuée au cadre' });
const body = req.body || {};
const err = profileError(body);
if (err) return res.status(400).json({ error: err });
const { full_name, email, role, company_id } = body;
const active = body.active !== false;
// An admin locking themselves out (or the last admin disappearing) can only
// be undone directly in the database.
if (String(req.user.id) === req.params.id && (!active || role !== 'admin')) {
return res.status(400).json({ error: 'Vous ne pouvez pas désactiver ni rétrograder votre propre compte' });
}
try {
const { rows } = await db.query(
`UPDATE users SET full_name = $1, email = $2, role = $3, company_id = $4, active = $5
WHERE id = $6
RETURNING id, full_name, email, role, company_id, active, created_at`,
[
full_name.trim(),
email.trim().toLowerCase(),
role,
role === 'admin' ? null : company_id,
active !== false,
req.params.id,
]
RETURNING ${USER_COLUMNS}`,
[full_name.trim(), email.trim().toLowerCase(), role, role === 'admin' ? null : company_id, active, req.params.id]
);
if (!rows[0]) return res.status(404).json({ error: 'Utilisateur introuvable' });
res.json(rows[0]);
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Cet email existe déjà' });
throw err;
} catch (e) {
dbError(e, res);
}
});
router.put('/:id/password', async (req, res) => {
const { password } = req.body || {};
if (!password || password.length < 6) {
return res.status(400).json({ error: 'Mot de passe trop court (6 caractères minimum)' });
}
const err = passwordError(password);
if (err) return res.status(400).json({ error: err });
const hash = await bcrypt.hash(password, 10);
const { rowCount } = await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [
hash,
req.params.id,
]);
// Also ends that user's open sessions.
const { rowCount } = await db.query(
'UPDATE users SET password_hash = $1, password_changed_at = now() WHERE id = $2',
[hash, req.params.id]
);
if (!rowCount) return res.status(404).json({ error: 'Utilisateur introuvable' });
if (String(req.user.id) === req.params.id) setAuthCookie(res, signToken(req.user));
res.json({ ok: true });
});
router.delete('/:id', async (req, res) => {
if (String(req.user.id) === String(req.params.id)) {
if (String(req.user.id) === req.params.id) {
return res.status(400).json({ error: 'Vous ne pouvez pas supprimer votre propre compte' });
}
const { rowCount } = await db.query('DELETE FROM users WHERE id = $1', [req.params.id]);
let rowCount;
try {
({ rowCount } = await db.query('DELETE FROM users WHERE id = $1', [req.params.id]));
} catch (e) {
// Referenced as the author of a company validation.
if (e.code === '23503') return res.status(409).json({ error: 'Compte référencé par des validations : désactivez-le plutôt' });
throw e;
}
if (!rowCount) return res.status(404).json({ error: 'Utilisateur introuvable' });
res.json({ ok: true });
});
+12 -13
View File
@@ -1,15 +1,14 @@
const express = require('express');
const db = require('../db');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const { isId, parseYearMonth: parseYM, monthStart, idParam } = require('../utils/validate');
const router = express.Router();
router.use(requireAuth);
router.param('companyId', idParam);
function parseYearMonth(req) {
const year = parseInt(req.body?.year ?? req.query?.year, 10);
const month = parseInt(req.body?.month ?? req.query?.month, 10);
if (!year || !month || month < 1 || month > 12) return null;
return { year, month };
return parseYM(req.body?.year ?? req.query?.year, req.body?.month ?? req.query?.month);
}
// Cadre validates their own month.
@@ -19,6 +18,7 @@ router.post('/cadre', async (req, res) => {
// Admin can validate on behalf of a cadre (e.g. corrections), otherwise self only.
const targetUserId = req.user.role === 'admin' && req.body.user_id ? req.body.user_id : req.user.id;
if (!isId(String(targetUserId))) return res.status(400).json({ error: 'Utilisateur invalide' });
const { rows: userRows } = await db.query('SELECT id, company_id FROM users WHERE id = $1', [targetUserId]);
const target = userRows[0];
@@ -46,7 +46,7 @@ router.post('/cadre', async (req, res) => {
router.post('/cadre/reopen', requireAdmin, async (req, res) => {
const ym = parseYearMonth(req);
const { user_id } = req.body || {};
if (!ym || !user_id) return res.status(400).json({ error: 'Paramètres invalides' });
if (!ym || !isId(String(user_id ?? ''))) return res.status(400).json({ error: 'Paramètres invalides' });
await db.query(
`INSERT INTO month_locks (user_id, year, month, cadre_validated, cadre_validated_at)
@@ -60,12 +60,11 @@ router.post('/cadre/reopen', requireAdmin, async (req, res) => {
// Admin: status of every cadre in a company for a given month.
router.get('/company/:companyId', requireAdmin, async (req, res) => {
const year = parseInt(req.query.year, 10);
const month = parseInt(req.query.month, 10);
const ym = parseYearMonth(req);
if (!ym) return res.status(400).json({ error: 'year et month requis' });
const { year, month } = ym;
const companyId = req.params.companyId;
if (!year || !month) return res.status(400).json({ error: 'year et month requis' });
const start = `${year}-${String(month).padStart(2, '0')}-01`;
const start = monthStart(ym);
const { rows: cadres } = await db.query(
`SELECT u.id, u.full_name, u.email,
ml.cadre_validated, ml.cadre_validated_at,
@@ -103,9 +102,9 @@ router.get('/company/:companyId', requireAdmin, async (req, res) => {
// Admin: one-shot overview of every company for a given month (dashboard).
router.get('/overview', requireAdmin, 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);
if (!ym) return res.status(400).json({ error: 'year et month requis' });
const { year, month } = ym;
const { rows } = await db.query(
`SELECT c.id, c.name,
+56
View File
@@ -0,0 +1,56 @@
// Input checks shared by the routes. Anything that reaches SQL unchecked and
// is malformed (an id like "abc", a month 13, a date "2026-02-31") makes
// Postgres throw, which surfaces as a 500 instead of a 400.
const MAX_INT = 2147483647;
function isId(v) {
if (typeof v === 'number') return Number.isInteger(v) && v > 0 && v <= MAX_INT;
return typeof v === 'string' && /^\d{1,10}$/.test(v) && Number(v) > 0 && Number(v) <= MAX_INT;
}
function isDate(s) {
if (typeof s !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
const d = new Date(`${s}T00:00:00Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s;
}
// Returns { year, month } or null.
function parseYearMonth(year, month) {
const y = parseInt(year, 10);
const m = parseInt(month, 10);
if (!(y >= 2000 && y <= 2100) || !(m >= 1 && m <= 12)) return null;
return { year: y, month: m };
}
function monthStart({ year, month }) {
return `${year}-${String(month).padStart(2, '0')}-01`;
}
function isEmail(s) {
return typeof s === 'string' && s.length <= 255 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);
}
function isName(s) {
return typeof s === 'string' && s.trim().length > 0 && s.trim().length <= 255;
}
const MIN_PASSWORD = 8;
function passwordError(p) {
if (typeof p !== 'string' || p.length < MIN_PASSWORD) {
return `Mot de passe trop court (${MIN_PASSWORD} caractères minimum)`;
}
// bcrypt ignores everything past 72 bytes.
if (Buffer.byteLength(p) > 72) return 'Mot de passe trop long (72 octets maximum)';
return null;
}
// Express router.param handler: rejects non-numeric ids with a 400.
function idParam(req, res, next, value) {
if (!isId(value)) return res.status(400).json({ error: 'Identifiant invalide' });
next();
}
module.exports = {
isId, isDate, parseYearMonth, monthStart, isEmail, isName, passwordError, idParam, MIN_PASSWORD,
};