From 3df521ac30352a7486fe94f42be6666daa0da55f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:17:54 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Revue=20compl=C3=A8te=20:=20s=C3=A9curit?= =?UTF-8?q?=C3=A9,=20optimisations=20et=20UI/UX=20mobile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01D5Bdayziw6tybgqETZoNSt --- README.md | 32 ++- backend/.dockerignore | 3 + backend/Dockerfile | 9 +- backend/migrations/002_security.sql | 10 + backend/src/bootstrap.js | 20 +- backend/src/db.js | 7 +- backend/src/index.js | 27 ++- backend/src/middleware/auth.js | 76 +++++-- backend/src/routes/attendance.js | 279 ++++++++++------------- backend/src/routes/auth.js | 110 ++++++--- backend/src/routes/companies.js | 6 +- backend/src/routes/export.js | 55 +++-- backend/src/routes/users.js | 113 +++++----- backend/src/routes/validations.js | 25 +- backend/src/utils/validate.js | 56 +++++ docker-compose.yml | 10 +- frontend/Dockerfile | 1 + frontend/nginx.conf | 9 + frontend/public/css/app.css | 41 +++- frontend/public/index.html | 10 +- frontend/public/js/app.js | 339 +++++++++++++++++++++------- frontend/security-headers.conf | 7 + 22 files changed, 830 insertions(+), 415 deletions(-) create mode 100644 backend/.dockerignore create mode 100644 backend/migrations/002_security.sql create mode 100644 backend/src/utils/validate.js create mode 100644 frontend/security-headers.conf diff --git a/README.md b/README.md index 94505e7..40807fe 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Application de gestion des présences par demi-journée, multi-sociétés, avec ## Démarrage -Toute la configuration (ports, mot de passe base de données, `JWT_SECRET`, identifiants admin) est définie directement dans `docker-compose.yml` — il n'y a pas de fichier `.env` à créer. Éditez les valeurs dans `docker-compose.yml` avant le premier démarrage (au minimum `POSTGRES_PASSWORD`, `JWT_SECRET` et `ADMIN_PASSWORD`), puis : +Toute la configuration (ports, mot de passe base de données, `JWT_SECRET`, identifiants admin) est définie directement dans `docker-compose.yml` — il n'y a pas de fichier `.env` à créer. Éditez les valeurs dans `docker-compose.yml` avant le premier démarrage (au minimum `POSTGRES_PASSWORD` et `ADMIN_PASSWORD`), puis : ```bash docker compose up -d --build @@ -27,15 +27,24 @@ docker compose up -d --build Ports par défaut (modifiables directement dans `docker-compose.yml`) : -| Service | Port hôte | -|-----------|-----------| -| Frontend | 8781 | -| API | 4790 | -| Postgres | 6543 | +| Service | Port hôte | Accessible depuis | +|-----------|-----------|--------------------------| +| Frontend | 8781 | le réseau | +| API | 4790 | la machine hôte seulement | +| Postgres | 6543 | la machine hôte seulement | + +Le navigateur passe toujours par le frontend (qui proxifie `/api`) ; l'API et +Postgres ne sont publiés que sur `127.0.0.1`, pour l'administration locale +(psql, sauvegardes). + +**`JWT_SECRET`** : laissez-le vide pour que l'API génère un secret aléatoire, +conservé en base (les sessions survivent aux redémarrages). Si vous le +renseignez, il doit faire au moins 32 caractères aléatoires ; une valeur +d'exemple ou trop courte est ignorée. Ouvrir http://localhost:8781 -Un compte administrateur est créé automatiquement au premier démarrage avec les identifiants définis par `ADMIN_EMAIL` / `ADMIN_PASSWORD` dans `docker-compose.yml` (par défaut `admin@presencia.local` / `ChangeMe123!`). **Changez ce mot de passe après la première connexion** (aucune page de changement de mot de passe en libre-service n'est fournie côté cadre ; un administrateur peut réinitialiser le mot de passe de n'importe quel compte depuis l'onglet Utilisateurs). +Un compte administrateur est créé automatiquement au premier démarrage avec les identifiants définis par `ADMIN_EMAIL` / `ADMIN_PASSWORD` dans `docker-compose.yml` (par défaut `admin@presencia.local` / `ChangeMe123!`). **Changez ce mot de passe après la première connexion** via « Mon mot de passe » (en bas de la barre latérale, ou l'icône cadenas sur mobile). Chaque utilisateur peut changer le sien ; un administrateur peut aussi réinitialiser celui de n'importe quel compte depuis l'onglet Utilisateurs. Si vous changez les identifiants admin dans `docker-compose.yml` *après* un premier démarrage, ils n'auront aucun effet : le compte admin n'est créé qu'une seule fois (au premier démarrage, base vide). Pour le modifier ensuite, utilisez l'écran Utilisateurs une fois connecté, ou réinitialisez le volume `presencia_pgdata`. @@ -56,6 +65,15 @@ Si vous changez les identifiants admin dans `docker-compose.yml` *après* un pre - En fin de mois, cliquer sur **« Valider mon mois »** : les saisies sont alors verrouillées et transmises pour validation à l'administrateur. Si une correction est nécessaire après coup, il faut qu'un administrateur réouvre le mois. - **Historique** : retrouver les mois précédents et leurs totaux, et les rouvrir en lecture. +## Sécurité + +- Mots de passe hachés (bcrypt), 8 caractères minimum. +- Session par cookie `httpOnly` / `SameSite=Lax` (12 h). Passez `COOKIE_SECURE: "true"` si l'application est servie en HTTPS. +- Le compte est relu en base à chaque requête : désactiver un compte, changer son rôle ou réinitialiser son mot de passe prend effet immédiatement, sans attendre l'expiration de la session. +- Connexion limitée à 10 échecs par adresse e-mail sur 15 minutes. +- Un administrateur ne peut ni désactiver ni rétrograder son propre compte. +- En-têtes de sécurité (CSP, `X-Frame-Options`, `nosniff`…) posés par Nginx et par l'API. + ## Architecture technique ``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..c81b8d3 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +npm-debug.log +.env diff --git a/backend/Dockerfile b/backend/Dockerfile index d984c81..fb29963 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/migrations/002_security.sql b/backend/migrations/002_security.sql new file mode 100644 index 0000000..c3d5c0e --- /dev/null +++ b/backend/migrations/002_security.sql @@ -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; diff --git a/backend/src/bootstrap.js b/backend/src/bootstrap.js index 32a57b3..92205f7 100644 --- a/backend/src/bootstrap.js +++ b/backend/src/bootstrap.js @@ -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(); } diff --git a/backend/src/db.js b/backend/src/db.js index 434eb7d..08cc478 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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', diff --git a/backend/src/index.js b/backend/src/index.js index 905ed33..a82df35 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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' }); }); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 7225991..7373d96 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -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, diff --git a/backend/src/routes/attendance.js b/backend/src/routes/attendance.js index 705970b..2e02792 100644 --- a/backend/src/routes/attendance.js +++ b/backend/src/routes/attendance.js @@ -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', diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 1b14935..61cae37 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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; diff --git a/backend/src/routes/companies.js b/backend/src/routes/companies.js index 55c6ac9..352a190 100644 --- a/backend/src/routes/companies.js +++ b/backend/src/routes/companies.js @@ -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', diff --git a/backend/src/routes/export.js b/backend/src/routes/export.js index 5616c03..3fc6194 100644 --- a/backend/src/routes/export.js +++ b/backend/src/routes/export.js @@ -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, diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index eab574c..919df9f 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -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 }); }); diff --git a/backend/src/routes/validations.js b/backend/src/routes/validations.js index 604a0e1..ec6e5d8 100644 --- a/backend/src/routes/validations.js +++ b/backend/src/routes/validations.js @@ -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, diff --git a/backend/src/utils/validate.js b/backend/src/utils/validate.js new file mode 100644 index 0000000..044c93d --- /dev/null +++ b/backend/src/utils/validate.js @@ -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, +}; diff --git a/docker-compose.yml b/docker-compose.yml index d7145dd..91beadc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,8 @@ services: volumes: - presencia_pgdata:/var/lib/postgresql/data ports: - - "6543:5432" + # Reachable from this host only (psql, backups), not from the network. + - "127.0.0.1:6543:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U presencia"] interval: 5s @@ -29,13 +30,16 @@ services: PGUSER: presencia PGPASSWORD: R4dsITu0vuigNQQwNlvj0agVyHupSyX PGDATABASE: presencia - JWT_SECRET: change-me-to-a-long-random-string + # Leave empty to let the API generate a random secret, stored in the + # database. If set, it must be at least 32 random characters. + JWT_SECRET: "" COOKIE_SECURE: "false" ADMIN_EMAIL: michaelschal@ffest.fr ADMIN_PASSWORD: Lapîn2509 ADMIN_NAME: Michael ports: - - "4790:4790" + # The browser goes through the frontend; direct API access stays local. + - "127.0.0.1:4790:4790" presencia-frontend: build: ./frontend diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 93dca21..72ec47b 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,6 +1,7 @@ FROM nginx:1.27-alpine COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY security-headers.conf /etc/nginx/snippets/security-headers.conf COPY public /usr/share/nginx/html EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 6e5d8e1..b0a2d44 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,7 @@ server { root /usr/share/nginx/html; index index.html; + server_tokens off; # Resolve the backend hostname at request time (via Docker's embedded DNS) # instead of once at startup, so a recreated backend container is picked @@ -24,6 +25,11 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + # The API sets these too (for direct access); keep a single copy. + proxy_hide_header X-Frame-Options; + proxy_hide_header X-Content-Type-Options; + proxy_hide_header Referrer-Policy; + include /etc/nginx/snippets/security-headers.conf; } # index.html, app.js and the stylesheets keep the same filenames across @@ -34,16 +40,19 @@ server { # it only forces a revalidation, so unchanged files cost one 304. location ~* \.(?:html|js|css)$ { add_header Cache-Control "no-cache"; + include /etc/nginx/snippets/security-headers.conf; try_files $uri $uri/ /index.html; } # Fonts are content-stable and large; let them sit in the browser cache. location ~* \.(?:woff2|woff|ttf)$ { add_header Cache-Control "public, max-age=2592000"; + include /etc/nginx/snippets/security-headers.conf; } location / { add_header Cache-Control "no-cache"; + include /etc/nginx/snippets/security-headers.conf; try_files $uri $uri/ /index.html; } } diff --git a/frontend/public/css/app.css b/frontend/public/css/app.css index 6fd9e9a..a3374d3 100644 --- a/frontend/public/css/app.css +++ b/frontend/public/css/app.css @@ -52,9 +52,12 @@ body { margin: 0; } /* ── mobile chrome ── */ .mobile-head { align-items: center; gap: 10px; margin-bottom: 14px; } .mobile-title { font-size: 15px; font-weight: 500; margin-right: auto; } -.mobile-nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 40; background: var(--color-surface); border-top: 1px solid var(--color-divider); padding: 6px 8px; justify-content: space-around; } -.mobile-nav .navitem { flex-direction: column; gap: 3px; justify-content: center; text-align: center; padding: 6px 4px; font-size: 10px; } +.mobile-nav { position: fixed; left: 0; right: 0; bottom: 0; z-index: 40; background: var(--color-surface); border-top: 1px solid var(--color-divider); padding: 6px 4px calc(6px + env(safe-area-inset-bottom)); justify-content: space-around; gap: 2px; overflow-x: auto; } +.mobile-nav .navitem { position: relative; flex: 1 1 0; min-width: 52px; flex-direction: column; gap: 3px; justify-content: center; text-align: center; padding: 6px 2px; font-size: 10px; white-space: nowrap; } .mobile-nav .navitem .ph { font-size: 19px; } +.mobile-nav .navitem .ni-badge { position: absolute; top: 2px; left: calc(50% + 6px); margin: 0; padding: 0 5px; font-size: 10px; line-height: 15px; } +.mobile-head .chipbtn .ph { font-size: 15px; } +.chipbtn-icon { padding-inline: 9px; } /* ── generic stacks / heads ── */ .stack-20 { display: flex; flex-direction: column; gap: 20px; } @@ -72,6 +75,40 @@ body { margin: 0; } .cell-muted { color: var(--color-neutral-700); } .cell-actions { text-align: right; white-space: nowrap; } .empty-row td { color: var(--color-neutral-700); font-size: 13px; } +.cell-capitalize { text-transform: capitalize; } +.field-hint { display: block; margin-top: 4px; font-size: 12px; color: var(--color-neutral-700); } + +/* destructive actions read as such without shouting */ +.btn-danger-ghost { color: #a8412c; } +.btn-danger-ghost:hover:not(:disabled) { background: #fbeae6; } + +/* pending request on a button (forms, exports) */ +.btn[aria-busy="true"], .btn:disabled { cursor: progress; } +.btn:disabled:not([aria-busy]) { cursor: not-allowed; } + +/* ── tables as cards on phones ── + A 5–6 column table cannot fit 390 px; scrolling it sideways hides the + actions at the far end. Each row becomes a card instead, every cell + labelled with its column title (data-label, set by fillTable()). */ +@media (max-width: 640px) { + .content table.table { min-width: 0; } + .content .card:has(> table.table) { overflow-x: visible; } + .table-card { padding: 4px 12px; } + .table thead { display: none; } + .table, .table tbody, .table tr, .table td { display: block; width: 100%; } + .table tbody tr { padding: 10px 0; } + .table tbody tr:hover { background: linear-gradient(color-mix(in srgb, var(--color-text) 8%, transparent), color-mix(in srgb, var(--color-text) 8%, transparent)) no-repeat bottom / 100% 1px; } + .table tbody tr:last-child { background: none; } + .table td { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 3px 0; text-align: right; min-width: 0; overflow-wrap: anywhere; } + .table td::before { content: attr(data-label); flex: none; font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--color-neutral-700); text-align: left; } + .table td:first-child { font-weight: 500; justify-content: flex-start; text-align: left; } + .table td:first-child::before { display: none; } + .table td.cell-actions { justify-content: flex-end; flex-wrap: wrap; gap: 4px; white-space: normal; padding-top: 6px; } + .table td.cell-actions::before { display: none; } + .table td.cell-actions .btn { min-height: 36px; } + .empty-row td { justify-content: flex-start; text-align: left; } + .empty-row td::before { display: none; } +} /* ── dashboard ── */ .tile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; } diff --git a/frontend/public/index.html b/frontend/public/index.html index 56a8d93..d92a952 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -65,6 +65,7 @@ + @@ -73,7 +74,8 @@
- + +
@@ -134,7 +136,7 @@
- + @@ -282,11 +284,11 @@ - + diff --git a/frontend/public/js/app.js b/frontend/public/js/app.js index 05025d3..24026ee 100644 --- a/frontend/public/js/app.js +++ b/frontend/public/js/app.js @@ -49,6 +49,12 @@ }); let data = null; try { data = await res.json(); } catch (e) { /* empty body */ } + // The session ended under us (expired, password reset, account + // disabled): go back to the login screen instead of failing every call. + if (res.status === 401 && state.user && !path.startsWith('/auth/')) { + location.reload(); + return new Promise(() => {}); + } if (!res.ok) { const err = new Error((data && data.error) || `Erreur ${res.status}`); err.status = res.status; @@ -62,12 +68,17 @@ const el = $('toast'); el.textContent = msg; el.classList.toggle('is-error', kind === 'error'); + el.setAttribute('role', kind === 'error' ? 'alert' : 'status'); el.hidden = false; clearTimeout(toastTimer); - toastTimer = setTimeout(() => { el.hidden = true; }, 3000); + // errors usually need reading, not just noticing + toastTimer = setTimeout(() => { el.hidden = true; }, kind === 'error' ? 6000 : 3000); } - const esc = (s) => { const d = document.createElement('div'); d.textContent = s == null ? '' : String(s); return d.innerHTML; }; + // Escapes quotes too: values are also interpolated into attributes + // (value="…", data-name="…"), where a bare " would end the attribute. + const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c]); const initials = (n) => String(n || '').trim().split(/\s+/).map((w) => w[0] || '').join('').slice(0, 2).toUpperCase(); const monthLabel = () => `${MONTHS[state.m - 1]} ${state.y}`; const iso = (d) => `${state.y}-${String(state.m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; @@ -79,6 +90,33 @@ return `${d.toLocaleDateString('fr-FR')} à ${d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`; } + // Fills a table body and copies each column title onto its cells + // (data-label): on a phone, rows are shown as cards labelled that way. + function fillTable(tbody, html) { + tbody.innerHTML = html; + const heads = [...tbody.closest('table').querySelectorAll('thead th')].map((th) => th.textContent.trim()); + tbody.querySelectorAll('tr').forEach((tr) => { + [...tr.children].forEach((td, i) => { if (heads[i]) td.dataset.label = heads[i]; }); + }); + } + + // Disables a form's submit button while `fn` runs: no double submission, + // and a visible sign that something is happening. + async function submitting(form, fn) { + const btn = form.querySelector('[type="submit"]'); + if (btn.disabled) return; + btn.disabled = true; + btn.setAttribute('aria-busy', 'true'); + try { + await fn(); + } catch (err) { + toast(err.message, 'error'); + } finally { + btn.disabled = false; + btn.removeAttribute('aria-busy'); + } + } + // ── auth ──────────────────────────────────────────────────────────────── async function boot() { let me; @@ -127,8 +165,10 @@ $('login-form').addEventListener('submit', async (e) => { e.preventDefault(); const errBox = $('login-error'); + const btn = e.target.querySelector('[type="submit"]'); errBox.hidden = true; let user; + btn.disabled = true; try { user = await api('/auth/login', { method: 'POST', @@ -137,7 +177,10 @@ } catch (err) { errBox.textContent = err.message || 'Connexion impossible'; errBox.hidden = false; + $('login-password').select(); return; + } finally { + btn.disabled = false; } // Past this point the login screen is gone, so its error box would be // invisible: report anything that goes wrong as a toast instead. @@ -154,29 +197,63 @@ } $('logout-btn').addEventListener('click', logout); $('logout-btn-mobile').addEventListener('click', logout); + $('my-password-btn').addEventListener('click', myPasswordDialog); + $('my-password-btn-mobile').addEventListener('click', myPasswordDialog); + + function myPasswordDialog() { + openDialog(` +

Changer mon mot de passe

+
+
+
+
+ + 8 caractères minimum. Vos autres sessions seront déconnectées.
+
+
+
+ + +
+
`); + $('mypw-form').addEventListener('submit', (e) => { + e.preventDefault(); + if ($('mypw-new').value !== $('mypw-confirm').value) { + $('mypw-confirm').setCustomValidity('Les deux mots de passe diffèrent'); + $('mypw-confirm').reportValidity(); + return; + } + submitting(e.target, async () => { + await api('/auth/password', { method: 'PUT', body: { current: $('mypw-current').value, password: $('mypw-new').value } }); + closeDialog(); + toast('Mot de passe mis à jour'); + }); + }); + $('mypw-confirm').addEventListener('input', (e) => e.target.setCustomValidity('')); + } // ── navigation ────────────────────────────────────────────────────────── function navGroups() { if (state.user.role === 'admin') { return [ { label: 'Pilotage', items: [ - { key: 'dashboard', label: 'Tableau de bord', icon: 'ph-squares-four' }, - { key: 'validation', label: 'Validation & export', icon: 'ph-seal-check' }, + { key: 'dashboard', label: 'Tableau de bord', short: 'Accueil', icon: 'ph-squares-four' }, + { key: 'validation', label: 'Validation & export', short: 'Validation', icon: 'ph-seal-check' }, ] }, { label: 'Équipes', items: [ - { key: 'plannings', label: 'Plannings des cadres', icon: 'ph-users-three' }, - { key: 'users', label: 'Utilisateurs', icon: 'ph-user-gear' }, + { key: 'plannings', label: 'Plannings des cadres', short: 'Plannings', icon: 'ph-users-three' }, + { key: 'users', label: 'Utilisateurs', short: 'Comptes', icon: 'ph-user-gear' }, ] }, - { label: 'Ma saisie', items: [{ key: 'planning', label: 'Mon planning', icon: 'ph-calendar-dots' }] }, - { label: 'Configuration', items: [{ key: 'companies', label: 'Sociétés', icon: 'ph-buildings' }] }, + { label: 'Ma saisie', items: [{ key: 'planning', label: 'Mon planning', short: 'Ma saisie', icon: 'ph-calendar-dots' }] }, + { label: 'Configuration', items: [{ key: 'companies', label: 'Sociétés', short: 'Sociétés', icon: 'ph-buildings' }] }, ]; } return [ { label: 'Mon mois', items: [ - { key: 'dashboard', label: 'Tableau de bord', icon: 'ph-squares-four' }, - { key: 'planning', label: 'Mon planning', icon: 'ph-calendar-dots' }, + { key: 'dashboard', label: 'Tableau de bord', short: 'Accueil', icon: 'ph-squares-four' }, + { key: 'planning', label: 'Mon planning', short: 'Planning', icon: 'ph-calendar-dots' }, ] }, - { label: 'Archives', items: [{ key: 'history', label: 'Historique', icon: 'ph-clock-counter-clockwise' }] }, + { label: 'Archives', items: [{ key: 'history', label: 'Historique', short: 'Historique', icon: 'ph-clock-counter-clockwise' }] }, ]; } @@ -196,18 +273,21 @@ `).join(''); - const flat = groups.reduce((a, g) => a.concat(g.items), []).slice(0, 4); + // Every destination, not just the first few: on a phone this bar is the + // only navigation there is. + const flat = groups.reduce((a, g) => a.concat(g.items), []); $('mobile-nav').innerHTML = flat.map((it) => ` - `).join(''); } function setNavBadge(key, count) { - const el = document.querySelector(`[data-badge="${key}"]`); - if (!el) return; - el.textContent = count || ''; - el.hidden = !count; + document.querySelectorAll(`[data-badge="${key}"]`).forEach((el) => { + el.textContent = count || ''; + el.hidden = !count; + }); } document.addEventListener('click', (e) => { @@ -368,7 +448,7 @@ tb.innerHTML = 'Aucune société. Créez-en une dans « Sociétés ».'; return; } - tb.innerHTML = companies.map((c) => { + fillTable(tb, companies.map((c) => { const ready = c.activeCadres > 0 && c.validatedCadres === c.activeCadres; const status = c.companyValidated ? 'Validée' : (ready ? 'Prête à valider' : 'En cours'); const tagCls = c.companyValidated ? 'tag-accent' : (ready ? 'tag-outline' : 'tag-neutral'); @@ -378,7 +458,7 @@ ${status} `; - }).join(''); + }).join('')); tb.querySelectorAll('[data-open-company]').forEach((b) => { b.addEventListener('click', () => { state.validationCompany = b.dataset.openCompany; go('validation'); }); }); @@ -401,10 +481,16 @@ return c; } + // Clicking ‹ › quickly fires overlapping loads; only the latest may render, + // or a slow earlier response would show the wrong month. + let planningReq = 0; async function loadPlanning() { + const req = ++planningReq; const qs = new URLSearchParams({ year: state.y, month: state.m }); if (state.viewing) qs.set('user_id', state.viewing.id); - state.cal = await api(`/attendance?${qs}`); + const cal = await api(`/attendance?${qs}`); + if (req !== planningReq) return; + state.cal = cal; renderPlanning(); } @@ -536,15 +622,38 @@ openPop(half, date, period); }); + // After a successful write, apply it to the month already in memory + // instead of reloading the month: painting stays fluid. `cal` is the month + // the write was made on; if the user moved to another month meanwhile, + // there is nothing to patch. + function patchEntries(cal, changes) { + if (state.cal !== cal) return; + const map = new Map((cal.entries || []).map((e) => [`${e.date}|${e.period}`, e])); + changes.forEach(({ date, period, status }) => { + if (status) map.set(`${date}|${period}`, { date, period, status }); + else map.delete(`${date}|${period}`); + }); + cal.entries = [...map.values()]; + renderPlanning(); + } + + // A rejected write (month locked meanwhile, session issue…) leaves the + // screen out of date: say why, then show what the server really has. + function writeFailed(err) { + toast(err.message, 'error'); + loadPlanning().catch(() => {}); + } + async function applyHalf(date, period, status) { closePop(); + const cal = state.cal; try { const body = status ? { date, period, status } : { date, period }; if (state.viewing) body.user_id = state.viewing.id; await api('/attendance', { method: status ? 'PUT' : 'DELETE', body }); - await loadPlanning(); + patchEntries(cal, [{ date, period, status }]); } catch (err) { - toast(err.message, 'error'); + writeFailed(err); } } @@ -554,13 +663,14 @@ const current = (state.cal.entries || []).find((e) => e.date === date && e.period === period); const status = (current && current.status) || state.brush || 'present'; closePop(); + const cal = state.cal; try { const body = { entries: [{ date, period: 'AM', status }, { date, period: 'PM', status }] }; if (state.viewing) body.user_id = state.viewing.id; await api('/attendance/bulk', { method: 'PUT', body }); - await loadPlanning(); + patchEntries(cal, body.entries); } catch (err) { - toast(err.message, 'error'); + writeFailed(err); } } @@ -650,20 +760,22 @@ const body = { entries }; if (state.viewing) body.user_id = state.viewing.id; await api('/attendance/bulk', { method: 'PUT', body }); - toast('Jours ouvrés vides remplis en « Présent »'); - await loadPlanning(); - } catch (err) { toast(err.message, 'error'); } + toast(`${entries.length} demi-journée(s) remplie(s) en « Présent »`); + patchEntries(data, entries); + } catch (err) { writeFailed(err); } }); $('clear-month-btn').addEventListener('click', async () => { - if (!confirm(`Effacer toutes les saisies de ${monthLabel()} ?`)) return; + const cal = state.cal; + if (!cal || !(cal.entries || []).length) return toast('Rien à effacer pour ce mois'); + if (!confirm(`Effacer les ${cal.entries.length} saisie(s) de ${monthLabel()} ? Cette action est définitive.`)) return; try { const body = { year: state.y, month: state.m }; if (state.viewing) body.user_id = state.viewing.id; await api('/attendance/month', { method: 'DELETE', body }); toast('Mois effacé'); - await loadPlanning(); - } catch (err) { toast(err.message, 'error'); } + patchEntries(cal, cal.entries.map((e) => ({ date: e.date, period: e.period, status: null }))); + } catch (err) { writeFailed(err); } }); // ── companies ─────────────────────────────────────────────────────────── @@ -685,14 +797,14 @@ tb.innerHTML = 'Aucune société pour l’instant.'; return; } - tb.innerHTML = state.companies.map((c) => ` + fillTable(tb, state.companies.map((c) => ` ${esc(c.name)} ${c.active_cadre_count} ${new Date(c.created_at).toLocaleDateString('fr-FR')} - - `).join(''); + + `).join('')); } $('companies-rows').addEventListener('click', async (e) => { @@ -714,25 +826,25 @@ function companyDialog(company) { openDialog(` -

${company ? 'Renommer la société' : 'Nouvelle société'}

+

${company ? 'Renommer la société' : 'Nouvelle société'}

-
+
`); - $('co-form').addEventListener('submit', async (e) => { + $('co-form').addEventListener('submit', (e) => { e.preventDefault(); const name = $('co-name').value.trim(); - try { + submitting(e.target, async () => { if (company) await api(`/companies/${company.id}`, { method: 'PUT', body: { name } }); else await api('/companies', { method: 'POST', body: { name } }); closeDialog(); toast('Société enregistrée'); await loadCompaniesView(); - } catch (err) { toast(err.message, 'error'); } + }); }); } @@ -745,17 +857,28 @@ await refreshUsers(); } + const matches = (u) => { + const q = state.search.trim().toLowerCase(); + return !q || `${u.full_name} ${u.email}`.toLowerCase().includes(q); + }; + + // Fetches only when the company filter changes; typing in the search box + // filters what is already loaded (renderUsers) instead of one request per key. async function refreshUsers() { const qs = state.filterCompany !== 'all' ? `?company_id=${state.filterCompany}` : ''; state.users = await api(`/users${qs}`); - const q = state.search.trim().toLowerCase(); - const rows = state.users.filter((u) => !q || `${u.full_name}${u.email}`.toLowerCase().includes(q)); + renderUsers(); + } + + function renderUsers() { + const rows = state.users.filter(matches); const tb = $('users-rows'); if (!rows.length) { - tb.innerHTML = 'Aucun utilisateur ne correspond.'; + tb.innerHTML = `${state.users.length ? 'Aucun utilisateur ne correspond à ce filtre.' : 'Aucun utilisateur.'}`; return; } - tb.innerHTML = rows.map((u) => ` + const self = String(state.user.id); + fillTable(tb, rows.map((u) => ` ${esc(u.full_name)} ${esc(u.email)} ${u.role === 'admin' ? 'Admin' : 'Cadre'} @@ -764,12 +887,12 @@ - - `).join(''); + ${String(u.id) === self ? '' : ``} + `).join('')); } $('us-co').addEventListener('change', (e) => { state.filterCompany = e.target.value; refreshUsers().catch((x) => toast(x.message, 'error')); }); - $('us-q').addEventListener('input', (e) => { state.search = e.target.value; refreshUsers().catch((x) => toast(x.message, 'error')); }); + $('us-q').addEventListener('input', (e) => { state.search = e.target.value; renderUsers(); }); $('users-rows').addEventListener('click', async (e) => { const ed = e.target.closest('[data-u-edit]'); @@ -780,6 +903,7 @@ if (pw) return passwordDialog(find(pw.dataset.uPw)); if (tg) { const u = find(tg.dataset.uToggle); + if (u.active && !confirm(`Désactiver le compte de ${u.full_name} ? Il ne pourra plus se connecter.`)) return; try { await api(`/users/${u.id}`, { method: 'PUT', @@ -796,14 +920,15 @@ function userDialog(user) { const edit = !!user; openDialog(` -

${edit ? 'Modifier l’utilisateur' : 'Nouvel utilisateur'}

+

${edit ? 'Modifier l’utilisateur' : 'Nouvel utilisateur'}

${edit ? '' : `
-
`} + + 8 caractères minimum. À transmettre à la personne.`}
+ + 8 caractères minimum. Ses sessions ouvertes seront déconnectées.
`); - $('pw-form').addEventListener('submit', async (e) => { + $('pw-form').addEventListener('submit', (e) => { e.preventDefault(); - try { + submitting(e.target, async () => { await api(`/users/${user.id}/password`, { method: 'PUT', body: { password: $('pw-new').value } }); closeDialog(); toast('Mot de passe mis à jour'); - } catch (err) { toast(err.message, 'error'); } + }); }); } @@ -883,34 +1014,37 @@ await refreshCadreRows(); } + // Loads cadres and, per company, their month status (all companies in + // parallel). The search box then filters locally (renderCadreRows). + let cadreData = { cadres: [], byId: new Map() }; async function refreshCadreRows() { const qs = state.filterCompany !== 'all' ? `?company_id=${state.filterCompany}` : ''; - const users = await api(`/users${qs}`); - const q = state.search.trim().toLowerCase(); - const cadres = users.filter((u) => u.role === 'cadre' && (!q || `${u.full_name}${u.email}`.toLowerCase().includes(q))); - - // validation state per company, so each row can show Validé / En cours - const byCompany = new Map(); - for (const cid of new Set(cadres.map((c) => c.company_id).filter(Boolean))) { - try { - const v = await api(`/validations/company/${cid}?year=${state.y}&month=${state.m}`); - v.cadres.forEach((c) => byCompany.set(c.id, c)); - } catch (e) { /* company may have been removed */ } - } + const cadres = (await api(`/users${qs}`)).filter((u) => u.role === 'cadre'); + const byId = new Map(); + const companyIds = [...new Set(cadres.map((c) => c.company_id).filter(Boolean))]; + await Promise.all(companyIds.map((cid) => api(`/validations/company/${cid}?year=${state.y}&month=${state.m}`) + .then((v) => v.cadres.forEach((c) => byId.set(c.id, c))) + .catch(() => { /* company may have been removed */ }))); + cadreData = { cadres, byId }; + renderCadreRows(); + } + function renderCadreRows() { + const { byId } = cadreData; + const cadres = cadreData.cadres.filter(matches); const box = $('cadre-rows'); if (!cadres.length) { - box.innerHTML = '

Aucun cadre ne correspond à ce filtre.

'; + box.innerHTML = `

${cadreData.cadres.length ? 'Aucun cadre ne correspond à ce filtre.' : 'Aucun cadre. Créez des comptes « Cadre » dans Utilisateurs.'}

`; return; } const work = workdayHalves(); box.innerHTML = cadres.map((u) => { - const v = byCompany.get(u.id); + const v = byId.get(u.id); const filled = v ? v.filled : 0; const status = !u.active ? 'Inactif' : (v && v.cadreValidated ? 'Validé' : 'En cours'); const tagCls = !u.active ? 'tag-neutral' : (v && v.cadreValidated ? 'tag-accent' : 'tag-outline'); return ` ${c.cadreValidated && !data.companyValidated ? `` : ''} - `).join(''); + `).join('')); } $('va-co').addEventListener('change', (e) => { state.validationCompany = e.target.value; refreshValidation().catch((x) => toast(x.message, 'error')); }); @@ -1036,16 +1170,37 @@ } catch (err) { toast(err.message, 'error'); } }); - $('export-pdf-btn').addEventListener('click', () => download('pdf')); - $('export-excel-btn').addEventListener('click', () => download('excel')); - function download(kind) { - if (!state.validationCompany) return; - const a = document.createElement('a'); - a.href = `/api/export/${kind}/${state.validationCompany}?year=${state.y}&month=${state.m}`; - a.download = ''; - document.body.appendChild(a); - a.click(); - a.remove(); + $('export-pdf-btn').addEventListener('click', (e) => download('pdf', e.currentTarget)); + $('export-excel-btn').addEventListener('click', (e) => download('excel', e.currentTarget)); + + // Fetched rather than navigated to, so that a failure shows as a message + // instead of downloading a file that contains the JSON error. + async function download(kind, btn) { + if (!state.validationCompany || btn.disabled) return; + btn.disabled = true; + try { + const res = await fetch(`/api/export/${kind}/${state.validationCompany}?year=${state.y}&month=${state.m}`, { credentials: 'include' }); + if (!res.ok) { + let msg = `Erreur ${res.status}`; + try { msg = (await res.json()).error || msg; } catch (e) { /* not JSON */ } + throw new Error(msg); + } + const cd = res.headers.get('Content-Disposition') || ''; + const m = cd.match(/filename\*=UTF-8''([^;]+)/) || cd.match(/filename="([^"]+)"/); + const name = m ? decodeURIComponent(m[1]) : `presences.${kind === 'pdf' ? 'pdf' : 'xlsx'}`; + const url = URL.createObjectURL(await res.blob()); + const a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } catch (err) { + toast(err.message, 'error'); + } finally { + btn.disabled = false; + } } // ── history ───────────────────────────────────────────────────────────── @@ -1056,11 +1211,11 @@ tb.innerHTML = 'Aucune saisie enregistrée pour le moment.'; return; } - tb.innerHTML = rows.map((r) => { + fillTable(tb, rows.map((r) => { const status = r.companyValidated ? 'Validé société' : (r.cadreValidated ? 'Validé cadre' : 'En cours'); const tagCls = r.companyValidated ? 'tag-accent' : (r.cadreValidated ? 'tag-outline' : 'tag-neutral'); return ` - ${MONTHS[r.month - 1]} ${r.year} + ${MONTHS[r.month - 1]} ${r.year} ${halfToDays(r.present)} j ${halfToDays(r.absent)} j ${halfToDays(r.conge)} j @@ -1068,7 +1223,7 @@ ${status} `; - }).join(''); + }).join('')); } $('history-rows').addEventListener('click', (e) => { @@ -1080,14 +1235,24 @@ }); // ── dialog ────────────────────────────────────────────────────────────── + let dialogOpener = null; function openDialog(html) { + dialogOpener = document.activeElement; $('dialog').innerHTML = html; $('dialog-backdrop').hidden = false; + const first = $('dialog').querySelector('input:not([disabled]), select:not([disabled])'); + if (first) first.focus(); } function closeDialog() { $('dialog-backdrop').hidden = true; $('dialog').innerHTML = ''; + // back to the button that opened it, for keyboard users + if (dialogOpener && document.contains(dialogOpener)) dialogOpener.focus(); + dialogOpener = null; } + window.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape' && !$('dialog-backdrop').hidden) closeDialog(); + }); $('dialog-backdrop').addEventListener('click', (e) => { if (e.target === $('dialog-backdrop') || e.target.closest('[data-close]')) closeDialog(); }); diff --git a/frontend/security-headers.conf b/frontend/security-headers.conf new file mode 100644 index 0000000..7d5d75d --- /dev/null +++ b/frontend/security-headers.conf @@ -0,0 +1,7 @@ +# Included in every location: nginx drops server-level add_header directives +# in any location that declares its own add_header. +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "DENY" always; +add_header Referrer-Policy "same-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; +add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always; From 82fcaa1e0fb2b32a33b070a27d7f2b6515aa0aae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:22:33 +0000 Subject: [PATCH 2/2] Corrections issues de la revue de code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Limite de connexion par (e-mail, IP) et par IP : un tiers ne peut plus bloquer le compte admin depuis une autre adresse ; mémoire bornée. - Ids canoniques uniquement (« 07 » contournait la protection du propre compte de l'admin). - Export : un cadre réactivé entre deux requêtes ne fait plus planter. - /auth/me lit le compte déjà chargé par requireAuth ; /auth/password et e-mail non textuel ne renvoient plus 500. - Planning : une écriture réussie n'est plus perdue quand un rechargement du même mois répond avant elle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01D5Bdayziw6tybgqETZoNSt --- backend/src/index.js | 5 +++ backend/src/middleware/auth.js | 7 +++- backend/src/routes/auth.js | 74 ++++++++++++++++++++-------------- backend/src/routes/export.js | 4 +- backend/src/routes/users.js | 4 +- backend/src/utils/validate.js | 4 +- frontend/public/js/app.js | 11 +++-- 7 files changed, 68 insertions(+), 41 deletions(-) diff --git a/backend/src/index.js b/backend/src/index.js index a82df35..0263756 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -16,6 +16,11 @@ const app = express(); const PORT = process.env.PORT || 4790; app.disable('x-powered-by'); +// Requests arrive through nginx (and often a reverse proxy in front of it), +// both on private Docker networks: take the client address from +// X-Forwarded-For, trusting only private-network hops so it cannot be spoofed +// from the Internet. Used by the login rate limit. +app.set('trust proxy', 'loopback, linklocal, uniquelocal'); // The API is also reachable on its own port, without the nginx headers. app.use((req, res, next) => { res.set({ diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 7373d96..56f639c 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -66,8 +66,10 @@ async function requireAuth(req, res, next) { 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`, + `SELECT u.id, u.full_name, u.email, u.role, u.company_id, u.active, u.password_changed_at, + c.name AS company_name + FROM users u LEFT JOIN companies c ON c.id = u.company_id + WHERE u.id = $1`, [payload.id] ); const u = rows[0]; @@ -80,6 +82,7 @@ async function requireAuth(req, res, next) { id: u.id, role: u.role, companyId: u.company_id, + companyName: u.company_name, fullName: u.full_name, email: u.email, }; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 61cae37..d5bba1c 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -6,31 +6,47 @@ 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; +// Brute-force guard, two counters over WINDOW_MS: +// - per (email, IP): guessing one account's password. Keyed by IP too, so +// that someone failing on purpose cannot lock the real owner out from +// another address; +// - per IP, all emails together: trying a few passwords on many accounts. const WINDOW_MS = 15 * 60 * 1000; -const failures = new Map(); // email -> { count, since } +const MAX_PER_ACCOUNT = 10; +const MAX_PER_IP = 50; +const MAX_KEYS = 50000; // bounds memory whatever the attempt volume +const failures = new Map(); // key -> { 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 failuresOf(key, now) { + const f = failures.get(key); + if (f && f.since + WINDOW_MS > now) return f; + if (f) failures.delete(key); + return null; } -function recordFailure(email) { +// Milliseconds until a new attempt is allowed, 0 if allowed now. +function lockedFor(email, ip) { const now = Date.now(); - if (failures.size > 10000) { - for (const [k, f] of failures) if (f.since + WINDOW_MS <= now) failures.delete(k); + let wait = 0; + for (const [key, max] of [[`a:${ip}:${email}`, MAX_PER_ACCOUNT], [`i:${ip}`, MAX_PER_IP]]) { + const f = failuresOf(key, now); + if (f && f.count >= max) wait = Math.max(wait, f.since + WINDOW_MS - now); + } + return wait; +} + +function recordFailure(email, ip) { + const now = Date.now(); + for (const key of [`a:${ip}:${email}`, `i:${ip}`]) { + const f = failuresOf(key, now); + if (f) { + f.count += 1; + continue; + } + // Map keeps insertion order: the first key is the oldest window. + if (failures.size >= MAX_KEYS) failures.delete(failures.keys().next().value); + failures.set(key, { count: 1, since: now }); } - 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 @@ -54,7 +70,7 @@ router.post('/login', async (req, res) => { return res.status(400).json({ error: 'Email et mot de passe requis' }); } const key = email.trim().toLowerCase(); - const wait = lockedFor(key); + const wait = lockedFor(key, req.ip); if (wait) { const minutes = Math.ceil(wait / 60000); return res.status(429).json({ error: `Trop de tentatives. Réessayez dans ${minutes} min.` }); @@ -71,10 +87,10 @@ router.post('/login', async (req, res) => { const user = rows[0]; const ok = await bcrypt.compare(password, user ? user.password_hash : DUMMY_HASH); if (!user || !ok || !user.active) { - recordFailure(key); + recordFailure(key, req.ip); return res.status(401).json({ error: 'Identifiants incorrects' }); } - failures.delete(key); + failures.delete(`a:${req.ip}:${key}`); setAuthCookie(res, signToken(user)); res.json(publicUser(user)); }); @@ -84,15 +100,10 @@ router.post('/logout', (req, res) => { res.json({ ok: true }); }); -router.get('/me', requireAuth, async (req, res) => { - const { rows } = await db.query( - `SELECT u.id, u.full_name, u.email, u.role, u.company_id, c.name AS company_name - FROM users u - LEFT JOIN companies c ON c.id = u.company_id - WHERE u.id = $1`, - [req.user.id] - ); - res.json(publicUser(rows[0])); +// requireAuth has just read the account from the database. +router.get('/me', requireAuth, (req, res) => { + const { id, fullName, email, role, companyId, companyName } = req.user; + res.json({ id, fullName, email, role, companyId, companyName }); }); // Any signed-in user changes their own password; the current one is required. @@ -104,6 +115,7 @@ router.put('/password', requireAuth, async (req, res) => { 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 (!rows[0]) return res.status(401).json({ error: 'Session invalide ou expirée' }); if (!(await bcrypt.compare(current, rows[0].password_hash))) { return res.status(400).json({ error: 'Mot de passe actuel incorrect' }); } diff --git a/backend/src/routes/export.js b/backend/src/routes/export.js index 3fc6194..7b50631 100644 --- a/backend/src/routes/export.js +++ b/backend/src/routes/export.js @@ -31,7 +31,9 @@ async function loadCompanyData(companyId, 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); + // a cadre (re)activated between the two queries is simply left out + const cadre = byId.get(e.user_id); + if (cadre) cadre.entries.set(`${e.entry_date}:${e.period}`, e.status); } return { company, cadres }; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 919df9f..c55909f 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -12,8 +12,8 @@ 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 (!isName(full_name) || typeof email !== 'string' || !email || !role) return 'Champs requis manquants'; + if (!isEmail(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; diff --git a/backend/src/utils/validate.js b/backend/src/utils/validate.js index 044c93d..98da6db 100644 --- a/backend/src/utils/validate.js +++ b/backend/src/utils/validate.js @@ -6,7 +6,9 @@ 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; + // Canonical form only: '07' would reach SQL as 7 yet differ from '7' in + // the string comparisons that guard an admin's own account. + return typeof v === 'string' && /^[1-9]\d{0,9}$/.test(v) && Number(v) <= MAX_INT; } function isDate(s) { diff --git a/frontend/public/js/app.js b/frontend/public/js/app.js index 24026ee..bdcbe5d 100644 --- a/frontend/public/js/app.js +++ b/frontend/public/js/app.js @@ -51,7 +51,7 @@ try { data = await res.json(); } catch (e) { /* empty body */ } // The session ended under us (expired, password reset, account // disabled): go back to the login screen instead of failing every call. - if (res.status === 401 && state.user && !path.startsWith('/auth/')) { + if (res.status === 401 && state.user && path !== '/auth/login') { location.reload(); return new Promise(() => {}); } @@ -490,6 +490,7 @@ if (state.viewing) qs.set('user_id', state.viewing.id); const cal = await api(`/attendance?${qs}`); if (req !== planningReq) return; + cal.key = qs.toString(); state.cal = cal; renderPlanning(); } @@ -624,10 +625,12 @@ // After a successful write, apply it to the month already in memory // instead of reloading the month: painting stays fluid. `cal` is the month - // the write was made on; if the user moved to another month meanwhile, - // there is nothing to patch. + // the write was made on. It is matched by month and person, not by object: + // a reload that answered before this write landed must get it too, while a + // different month on screen must not. function patchEntries(cal, changes) { - if (state.cal !== cal) return; + if (!state.cal || state.cal.key !== cal.key) return; + cal = state.cal; const map = new Map((cal.entries || []).map((e) => [`${e.date}|${e.period}`, e])); changes.forEach(({ date, period, status }) => { if (status) map.set(`${date}|${period}`, { date, period, status });