mirror of
https://github.com/R0m1k3/Presencia.git
synced 2026-10-11 17:27:42 +02:00
Revue complète : sécurité, optimisations et UI/UX mobile
Sécurité - JWT : un JWT_SECRET d'exemple ou trop court est ignoré au profit d'un secret aléatoire conservé en base (avant, la valeur publique de docker-compose.yml permettait de forger un jeton admin). - Le compte est relu en base à chaque requête : désactivation, changement de rôle et réinitialisation du mot de passe prennent effet immédiatement. - Connexion : 10 échecs max par e-mail / 15 min, temps constant que l'e-mail existe ou non, mot de passe admin retiré des logs. - Validation des entrées (ids, dates, mois, longueurs, e-mail) : 400 au lieu de 500. Un admin ne peut plus se désactiver ni se rétrograder. - Mots de passe : 8 caractères minimum ; changement en libre-service. - Export : nom de fichier conforme RFC 5987 (un nom de société avec « — » faisait planter l'export). - esc() échappe aussi les guillemets (injection d'attributs HTML). - En-têtes CSP / X-Frame-Options / nosniff (Nginx + API), API et Postgres publiés sur 127.0.0.1 seulement, image backend non-root via npm ci. Optimisations - Export : une requête pour tout le mois au lieu d'une par cadre. - Saisie groupée : un seul INSERT (unnest), doublons dédupliqués. - Dates renvoyées en chaînes (plus de décalage d'un jour selon le TZ). - Planning : chaque clic met à jour l'affichage localement au lieu de recharger le mois ; les réponses de mois périmées sont ignorées. - Recherche Utilisateurs / Plannings filtrée localement (plus une requête par touche) ; statuts des sociétés chargés en parallèle. UI/UX - Mobile : toutes les destinations dans la barre basse (Sociétés et Ma saisie étaient inaccessibles à l'admin) ; tableaux affichés en cartes (les boutons d'action étaient hors écran). - Boutons désactivés pendant l'envoi, focus et Échap dans les dialogues, retour à l'écran de connexion quand la session est révoquée, erreurs d'export affichées au lieu d'un fichier JSON téléchargé, actions destructives signalées, confirmation avant désactivation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D5Bdayziw6tybgqETZoNSt
This commit is contained in:
22 files changed
+809
-394
No files matched your search
@@ -19,7 +19,7 @@ Application de gestion des présences par demi-journée, multi-sociétés, avec
|
|||||||
|
|
||||||
## Démarrage
|
## 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
|
```bash
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
@@ -27,15 +27,24 @@ docker compose up -d --build
|
|||||||
|
|
||||||
Ports par défaut (modifiables directement dans `docker-compose.yml`) :
|
Ports par défaut (modifiables directement dans `docker-compose.yml`) :
|
||||||
|
|
||||||
| Service | Port hôte |
|
| Service | Port hôte | Accessible depuis |
|
||||||
|-----------|-----------|
|
|-----------|-----------|--------------------------|
|
||||||
| Frontend | 8781 |
|
| Frontend | 8781 | le réseau |
|
||||||
| API | 4790 |
|
| API | 4790 | la machine hôte seulement |
|
||||||
| Postgres | 6543 |
|
| 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
|
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`.
|
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.
|
- 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.
|
- **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
|
## Architecture technique
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
+7
-2
@@ -2,12 +2,17 @@ FROM node:20-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json ./
|
# Install from the lockfile so the image gets exactly the tested versions.
|
||||||
RUN npm install --omit=dev
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
ENV PORT=4790
|
ENV PORT=4790
|
||||||
EXPOSE 4790
|
EXPOSE 4790
|
||||||
|
|
||||||
|
# Run as the unprivileged user shipped with the node image, not root.
|
||||||
|
USER node
|
||||||
|
|
||||||
CMD ["node", "src/index.js"]
|
CMD ["node", "src/index.js"]
|
||||||
@@ -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;
|
||||||
Vendored
+12
-6
@@ -2,6 +2,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
|
const { initJwtSecret } = require('./middleware/auth');
|
||||||
|
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -32,14 +33,18 @@ async function runMigrations() {
|
|||||||
);
|
);
|
||||||
if (rows[0].exists) {
|
if (rows[0].exists) {
|
||||||
console.log('Schéma déjà initialisé.');
|
console.log('Schéma déjà initialisé.');
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
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...');
|
console.log('Initialisation du schéma de base de données...');
|
||||||
await db.query(sql);
|
await db.query(readMigration('001_init.sql'));
|
||||||
console.log('Schéma créé.');
|
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() {
|
async function seedAdmin() {
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
@@ -60,7 +65,7 @@ async function seedAdmin() {
|
|||||||
console.log('========================================================');
|
console.log('========================================================');
|
||||||
console.log(' Compte administrateur créé :');
|
console.log(' Compte administrateur créé :');
|
||||||
console.log(` Email : ${email}`);
|
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(' Merci de le changer après la première connexion.');
|
||||||
console.log('========================================================');
|
console.log('========================================================');
|
||||||
}
|
}
|
||||||
@@ -68,6 +73,7 @@ async function seedAdmin() {
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await waitForDb();
|
await waitForDb();
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
await initJwtSecret();
|
||||||
await seedAdmin();
|
await seedAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -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({
|
const pool = new Pool({
|
||||||
host: process.env.PGHOST || 'db',
|
host: process.env.PGHOST || 'db',
|
||||||
|
|||||||
+26
-1
@@ -15,7 +15,18 @@ const exportRoutes = require('./routes/export');
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 4790;
|
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());
|
app.use(cookieParser());
|
||||||
if (process.env.CORS_ORIGIN) {
|
if (process.env.CORS_ORIGIN) {
|
||||||
app.use(cors({ origin: process.env.CORS_ORIGIN, credentials: true }));
|
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/validations', validationRoutes);
|
||||||
app.use('/api/export', exportRoutes);
|
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) => {
|
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);
|
console.error(err);
|
||||||
res.status(500).json({ error: 'Erreur interne du serveur' });
|
res.status(500).json({ error: 'Erreur interne du serveur' });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +1,40 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const db = require('../db');
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
|
|
||||||
const COOKIE_NAME = 'presencia_token';
|
const COOKIE_NAME = 'presencia_token';
|
||||||
|
|
||||||
function signToken(user) {
|
// Values that have shipped in this repository or its docs. Anyone can read
|
||||||
return jwt.sign(
|
// them, so a token signed with one of them can be forged by anyone.
|
||||||
{
|
const KNOWN_PLACEHOLDERS = new Set([
|
||||||
id: user.id,
|
'change-me-to-a-long-random-string',
|
||||||
role: user.role,
|
'dev-secret-change-me',
|
||||||
companyId: user.company_id,
|
]);
|
||||||
fullName: user.full_name,
|
|
||||||
email: user.email,
|
let jwtSecret = null;
|
||||||
},
|
|
||||||
JWT_SECRET,
|
// Called once at startup. Uses JWT_SECRET when it is a real secret; otherwise
|
||||||
{ expiresIn: '12h' }
|
// 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) {
|
function setAuthCookie(res, token) {
|
||||||
@@ -31,16 +51,39 @@ function clearAuthCookie(res) {
|
|||||||
res.clearCookie(COOKIE_NAME, { path: '/' });
|
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];
|
const token = req.cookies && req.cookies[COOKIE_NAME];
|
||||||
if (!token) return res.status(401).json({ error: 'Non authentifié' });
|
if (!token) return res.status(401).json({ error: 'Non authentifié' });
|
||||||
|
let payload;
|
||||||
try {
|
try {
|
||||||
const payload = jwt.verify(token, JWT_SECRET);
|
payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] });
|
||||||
req.user = payload;
|
|
||||||
next();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearAuthCookie(res);
|
||||||
return res.status(401).json({ error: 'Session invalide ou expirée' });
|
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) {
|
function requireAdmin(req, res, next) {
|
||||||
@@ -51,6 +94,7 @@ function requireAdmin(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
initJwtSecret,
|
||||||
signToken,
|
signToken,
|
||||||
setAuthCookie,
|
setAuthCookie,
|
||||||
clearAuthCookie,
|
clearAuthCookie,
|
||||||
|
|||||||
+102
-155
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const { requireAuth } = require('../middleware/auth');
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
const { isId, isDate, parseYearMonth, monthStart } = require('../utils/validate');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
@@ -8,103 +9,105 @@ router.use(requireAuth);
|
|||||||
const STATUSES = ['present', 'absent', 'conge', 'rtt'];
|
const STATUSES = ['present', 'absent', 'conge', 'rtt'];
|
||||||
const PERIODS = ['AM', 'PM'];
|
const PERIODS = ['AM', 'PM'];
|
||||||
|
|
||||||
async function getTargetUser(req, requestedUserId) {
|
// Resolves whose planning a request acts on. A cadre can only ever act on
|
||||||
// Cadre can only ever act on themselves. Admin may act on any user.
|
// themselves; an admin may pass user_id to act on anyone. Sends the error
|
||||||
if (req.user.role === 'admin' && requestedUserId) {
|
// response and returns null when the request is not allowed.
|
||||||
const { rows } = await db.query(
|
async function resolveTarget(req, res, requestedUserId) {
|
||||||
'SELECT id, role, company_id FROM users WHERE id = $1',
|
if (requestedUserId === undefined || requestedUserId === null || requestedUserId === '') {
|
||||||
[requestedUserId]
|
return { id: req.user.id, company_id: req.user.companyId };
|
||||||
);
|
|
||||||
return rows[0] || null;
|
|
||||||
}
|
}
|
||||||
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(
|
const { rows } = await db.query(
|
||||||
'SELECT cadre_validated, cadre_validated_at FROM month_locks WHERE user_id = $1 AND year = $2 AND month = $3',
|
`SELECT
|
||||||
[userId, year, month]
|
(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) {
|
// Same lock rules for every write: nobody edits a month the admin validated
|
||||||
if (!companyId) return { admin_validated: false, admin_validated_at: null };
|
// for the company; a cadre cannot edit a month they validated themselves.
|
||||||
const { rows } = await db.query(
|
// Sends a 423 and returns false when the month is locked.
|
||||||
'SELECT admin_validated, admin_validated_at FROM company_month_validations WHERE company_id = $1 AND year = $2 AND month = $3',
|
async function ensureWritable(req, res, target, ym) {
|
||||||
[companyId, year, month]
|
const st = await monthState(target, ym);
|
||||||
);
|
if (st.companyValidated) {
|
||||||
return rows[0] || { admin_validated: false, admin_validated_at: null };
|
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) => {
|
router.get('/', async (req, res) => {
|
||||||
const year = parseInt(req.query.year, 10);
|
const ym = parseYearMonth(req.query.year, req.query.month);
|
||||||
const month = parseInt(req.query.month, 10);
|
if (!ym) return res.status(400).json({ error: 'year et month requis' });
|
||||||
if (!year || !month) 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;
|
const [{ rows }, st] = await Promise.all([
|
||||||
if (requestedUserId && req.user.role !== 'admin') {
|
db.query(
|
||||||
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
|
`SELECT entry_date, period, status
|
||||||
FROM attendance_entries
|
FROM attendance_entries
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND entry_date >= $2::date
|
AND entry_date >= $2::date
|
||||||
AND entry_date < ($2::date + INTERVAL '1 month')
|
AND entry_date < ($2::date + INTERVAL '1 month')
|
||||||
ORDER BY entry_date, period`,
|
ORDER BY entry_date, period`,
|
||||||
[target.id, start]
|
[target.id, monthStart(ym)]
|
||||||
);
|
),
|
||||||
|
monthState(target, ym),
|
||||||
const lock = await getMonthLock(target.id, year, month);
|
]);
|
||||||
const companyValidation = await getCompanyValidation(target.company_id, year, month);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
userId: target.id,
|
userId: target.id,
|
||||||
entries: rows.map((r) => ({
|
entries: rows.map((r) => ({ date: r.entry_date, period: r.period, status: r.status })),
|
||||||
date: r.entry_date.toISOString().slice(0, 10),
|
...st,
|
||||||
period: r.period,
|
editable: req.user.role === 'admin'
|
||||||
status: r.status,
|
? !st.companyValidated
|
||||||
})),
|
: !st.cadreValidated && !st.companyValidated,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/', async (req, res) => {
|
router.put('/', async (req, res) => {
|
||||||
const { date, period, status, user_id: requestedUserId } = req.body || {};
|
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' });
|
return res.status(400).json({ error: 'Paramètres invalides' });
|
||||||
}
|
}
|
||||||
if (requestedUserId && req.user.role !== 'admin') {
|
const target = await resolveTarget(req, res, requestedUserId);
|
||||||
return res.status(403).json({ error: 'Accès refusé' });
|
if (!target) return;
|
||||||
}
|
if (!(await ensureWritable(req, res, target, ymOf(date)))) return;
|
||||||
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.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
`INSERT INTO attendance_entries (user_id, entry_date, period, status, updated_at)
|
`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()`,
|
DO UPDATE SET status = EXCLUDED.status, updated_at = now()`,
|
||||||
[target.id, date, period, status]
|
[target.id, date, period, status]
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Per-month aggregates for the history view: half-day counts by status plus
|
// Per-month aggregates for the history view: half-day counts by status plus
|
||||||
// both validation flags, most recent month first.
|
// both validation flags, most recent month first.
|
||||||
router.get('/history', async (req, res) => {
|
router.get('/history', async (req, res) => {
|
||||||
const requestedUserId = req.query.user_id;
|
const target = await resolveTarget(req, res, req.query.user_id);
|
||||||
if (requestedUserId && req.user.role !== 'admin') {
|
if (!target) return;
|
||||||
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 { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`WITH months AS (
|
`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
|
// Clear every entry of a month (the « Tout effacer » action).
|
||||||
// as writes.
|
|
||||||
router.delete('/month', async (req, res) => {
|
router.delete('/month', async (req, res) => {
|
||||||
const { year, month, user_id: requestedUserId } = req.body || {};
|
const { year, month, user_id: requestedUserId } = req.body || {};
|
||||||
const y = parseInt(year, 10);
|
const ym = parseYearMonth(year, month);
|
||||||
const m = parseInt(month, 10);
|
if (!ym) return res.status(400).json({ error: 'Paramètres invalides' });
|
||||||
if (!y || !m || m < 1 || m > 12) return res.status(400).json({ error: 'Paramètres invalides' });
|
const target = await resolveTarget(req, res, requestedUserId);
|
||||||
if (requestedUserId && req.user.role !== 'admin') {
|
if (!target) return;
|
||||||
return res.status(403).json({ error: 'Accès refusé' });
|
if (!(await ensureWritable(req, res, target, ym))) return;
|
||||||
}
|
|
||||||
const target = await getTargetUser(req, requestedUserId);
|
|
||||||
if (!target) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
|
||||||
|
|
||||||
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(
|
await db.query(
|
||||||
`DELETE FROM attendance_entries
|
`DELETE FROM attendance_entries
|
||||||
WHERE user_id = $1 AND entry_date >= $2::date AND entry_date < ($2::date + INTERVAL '1 month')`,
|
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 });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bulk upsert (e.g. « fill all empty weekdays with présent »). All entries
|
// Bulk upsert (e.g. « fill all empty weekdays with présent »). Every month
|
||||||
// must pass the same lock checks as single writes; months are checked once
|
// present in the payload must pass the same lock checks as single writes.
|
||||||
// per distinct month present in the payload.
|
|
||||||
router.put('/bulk', async (req, res) => {
|
router.put('/bulk', async (req, res) => {
|
||||||
const { entries, user_id: requestedUserId } = req.body || {};
|
const { entries, user_id: requestedUserId } = req.body || {};
|
||||||
if (!Array.isArray(entries) || entries.length === 0 || entries.length > 200) {
|
if (!Array.isArray(entries) || entries.length === 0 || entries.length > 200) {
|
||||||
return res.status(400).json({ error: 'Paramètres invalides' });
|
return res.status(400).json({ error: 'Paramètres invalides' });
|
||||||
}
|
}
|
||||||
for (const e of entries) {
|
if (entries.some((e) => !e || !isDate(e.date) || !PERIODS.includes(e.period) || !STATUSES.includes(e.status))) {
|
||||||
if (!e || !e.date || !PERIODS.includes(e.period) || !STATUSES.includes(e.status)) {
|
|
||||||
return res.status(400).json({ error: 'Paramètres invalides' });
|
return res.status(400).json({ error: 'Paramètres invalides' });
|
||||||
}
|
}
|
||||||
}
|
const target = await resolveTarget(req, res, requestedUserId);
|
||||||
if (requestedUserId && req.user.role !== 'admin') {
|
if (!target) return;
|
||||||
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 months = new Set(entries.map((e) => e.date.slice(0, 7)));
|
for (const ym of new Set(entries.map((e) => e.date.slice(0, 7)))) {
|
||||||
for (const ym of months) {
|
if (!(await ensureWritable(req, res, target, ymOf(ym)))) return;
|
||||||
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.' });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await db.pool.connect();
|
// One statement for the whole batch. A half-day listed twice would make
|
||||||
try {
|
// ON CONFLICT hit the same row twice and fail, so the last one wins.
|
||||||
await client.query('BEGIN');
|
const byKey = new Map(entries.map((e) => [`${e.date}|${e.period}`, e]));
|
||||||
for (const e of entries) {
|
const list = [...byKey.values()];
|
||||||
await client.query(
|
await db.query(
|
||||||
`INSERT INTO attendance_entries (user_id, entry_date, period, status, updated_at)
|
`INSERT INTO attendance_entries (user_id, entry_date, period, status, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, now())
|
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)
|
ON CONFLICT (user_id, entry_date, period)
|
||||||
DO UPDATE SET status = EXCLUDED.status, updated_at = now()`,
|
DO UPDATE SET status = EXCLUDED.status, updated_at = now()`,
|
||||||
[target.id, e.date, e.period, e.status]
|
[target.id, list.map((e) => e.date), list.map((e) => e.period), list.map((e) => e.status)]
|
||||||
);
|
);
|
||||||
}
|
|
||||||
await client.query('COMMIT');
|
|
||||||
} catch (err) {
|
|
||||||
await client.query('ROLLBACK');
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ ok: true, count: entries.length });
|
res.json({ ok: true, count: list.length });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/', async (req, res) => {
|
router.delete('/', async (req, res) => {
|
||||||
const { date, period, user_id: requestedUserId } = req.body || {};
|
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' });
|
return res.status(400).json({ error: 'Paramètres invalides' });
|
||||||
}
|
}
|
||||||
if (requestedUserId && req.user.role !== 'admin') {
|
const target = await resolveTarget(req, res, requestedUserId);
|
||||||
return res.status(403).json({ error: 'Accès refusé' });
|
if (!target) return;
|
||||||
}
|
if (!(await ensureWritable(req, res, target, ymOf(date)))) return;
|
||||||
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.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.query(
|
await db.query(
|
||||||
'DELETE FROM attendance_entries WHERE user_id = $1 AND entry_date = $2 AND period = $3',
|
'DELETE FROM attendance_entries WHERE user_id = $1 AND entry_date = $2 AND period = $3',
|
||||||
|
|||||||
+81
-27
@@ -2,40 +2,81 @@ const express = require('express');
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const { signToken, setAuthCookie, clearAuthCookie, requireAuth } = require('../middleware/auth');
|
const { signToken, setAuthCookie, clearAuthCookie, requireAuth } = require('../middleware/auth');
|
||||||
|
const { passwordError } = require('../utils/validate');
|
||||||
|
|
||||||
const router = express.Router();
|
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) => {
|
router.post('/login', async (req, res) => {
|
||||||
const { email, password } = req.body || {};
|
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' });
|
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(
|
const { rows } = await db.query(
|
||||||
`SELECT u.id, u.full_name, u.email, u.password_hash, u.role, u.active,
|
`SELECT u.id, u.full_name, u.email, u.password_hash, u.role, u.active,
|
||||||
u.company_id, c.name AS company_name
|
u.company_id, c.name AS company_name
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN companies c ON c.id = u.company_id
|
LEFT JOIN companies c ON c.id = u.company_id
|
||||||
WHERE lower(u.email) = lower($1)`,
|
WHERE lower(u.email) = $1`,
|
||||||
[email]
|
[key]
|
||||||
);
|
);
|
||||||
const user = rows[0];
|
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' });
|
return res.status(401).json({ error: 'Identifiants incorrects' });
|
||||||
}
|
}
|
||||||
const ok = await bcrypt.compare(password, user.password_hash);
|
failures.delete(key);
|
||||||
if (!ok) {
|
setAuthCookie(res, signToken(user));
|
||||||
return res.status(401).json({ error: 'Identifiants incorrects' });
|
res.json(publicUser(user));
|
||||||
}
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/logout', (req, res) => {
|
router.post('/logout', (req, res) => {
|
||||||
@@ -51,16 +92,29 @@ router.get('/me', requireAuth, async (req, res) => {
|
|||||||
WHERE u.id = $1`,
|
WHERE u.id = $1`,
|
||||||
[req.user.id]
|
[req.user.id]
|
||||||
);
|
);
|
||||||
if (!rows[0]) return res.status(401).json({ error: 'Non authentifié' });
|
res.json(publicUser(rows[0]));
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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;
|
module.exports = router;
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||||
|
const { isName, idParam } = require('../utils/validate');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireAuth, requireAdmin);
|
router.use(requireAuth, requireAdmin);
|
||||||
|
router.param('id', idParam);
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
@@ -19,7 +21,7 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { name } = req.body || {};
|
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 {
|
try {
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
'INSERT INTO companies (name) VALUES ($1) RETURNING id, name, created_at',
|
'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) => {
|
router.put('/:id', async (req, res) => {
|
||||||
const { name } = req.body || {};
|
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 {
|
try {
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
'UPDATE companies SET name = $1 WHERE id = $2 RETURNING id, name, created_at',
|
'UPDATE companies SET name = $1 WHERE id = $2 RETURNING id, name, created_at',
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ const db = require('../db');
|
|||||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||||
const { buildCompanyWorkbook } = require('../utils/excel');
|
const { buildCompanyWorkbook } = require('../utils/excel');
|
||||||
const { buildCompanyPdf } = require('../utils/pdf');
|
const { buildCompanyPdf } = require('../utils/pdf');
|
||||||
|
const { parseYearMonth, monthStart, idParam } = require('../utils/validate');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireAuth, requireAdmin);
|
router.use(requireAuth, requireAdmin);
|
||||||
|
router.param('companyId', idParam);
|
||||||
|
|
||||||
async function loadCompanyData(companyId, year, month) {
|
async function loadCompanyData(companyId, year, month) {
|
||||||
const { rows: companyRows } = await db.query('SELECT id, name FROM companies WHERE id = $1', [companyId]);
|
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]
|
[companyId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const start = `${year}-${String(month).padStart(2, '0')}-01`;
|
// All entries of the month in one query, rather than one query per cadre.
|
||||||
const cadres = [];
|
const { rows: entryRows } = await db.query(
|
||||||
for (const c of cadreRows) {
|
`SELECT ae.user_id, ae.entry_date, ae.period, ae.status
|
||||||
const { rows: entries } = await db.query(
|
FROM attendance_entries ae
|
||||||
`SELECT entry_date, period, status FROM attendance_entries
|
JOIN users u ON u.id = ae.user_id
|
||||||
WHERE user_id = $1 AND entry_date >= $2::date AND entry_date < ($2::date + INTERVAL '1 month')`,
|
WHERE u.company_id = $1 AND u.role = 'cadre' AND u.active = true
|
||||||
[c.id, start]
|
AND ae.entry_date >= $2::date AND ae.entry_date < ($2::date + INTERVAL '1 month')`,
|
||||||
|
[companyId, monthStart({ year, month })]
|
||||||
);
|
);
|
||||||
const map = new Map();
|
const cadres = cadreRows.map((c) => ({ id: c.id, fullName: c.full_name, entries: new Map() }));
|
||||||
for (const e of entries) {
|
const byId = new Map(cadres.map((c) => [c.id, c]));
|
||||||
map.set(`${e.entry_date.toISOString().slice(0, 10)}:${e.period}`, e.status);
|
for (const e of entryRows) {
|
||||||
}
|
byId.get(e.user_id).entries.set(`${e.entry_date}:${e.period}`, e.status);
|
||||||
cadres.push({ id: c.id, fullName: c.full_name, entries: map });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { company, cadres };
|
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) => {
|
router.get('/excel/:companyId', async (req, res) => {
|
||||||
const year = parseInt(req.query.year, 10);
|
const ym = parseYearMonth(req.query.year, req.query.month);
|
||||||
const month = parseInt(req.query.month, 10);
|
if (!ym) return res.status(400).json({ error: 'year et month requis' });
|
||||||
if (!year || !month) return res.status(400).json({ error: 'year et month requis' });
|
const { year, month } = ym;
|
||||||
|
|
||||||
const data = await loadCompanyData(req.params.companyId, year, month);
|
const data = await loadCompanyData(req.params.companyId, year, month);
|
||||||
if (!data) return res.status(404).json({ error: 'Société introuvable' });
|
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,
|
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-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);
|
await workbook.xlsx.write(res);
|
||||||
res.end();
|
res.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/pdf/:companyId', async (req, res) => {
|
router.get('/pdf/:companyId', async (req, res) => {
|
||||||
const year = parseInt(req.query.year, 10);
|
const ym = parseYearMonth(req.query.year, req.query.month);
|
||||||
const month = parseInt(req.query.month, 10);
|
if (!ym) return res.status(400).json({ error: 'year et month requis' });
|
||||||
if (!year || !month) return res.status(400).json({ error: 'year et month requis' });
|
const { year, month } = ym;
|
||||||
|
|
||||||
const data = await loadCompanyData(req.params.companyId, year, month);
|
const data = await loadCompanyData(req.params.companyId, year, month);
|
||||||
if (!data) return res.status(404).json({ error: 'Société introuvable' });
|
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-Type', 'application/pdf');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
res.setHeader('Content-Disposition', attachment(data.company.name, year, month, 'pdf'));
|
||||||
|
|
||||||
const doc = buildCompanyPdf({
|
const doc = buildCompanyPdf({
|
||||||
companyName: data.company.name,
|
companyName: data.company.name,
|
||||||
|
|||||||
+60
-53
@@ -1,109 +1,116 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const db = require('../db');
|
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();
|
const router = express.Router();
|
||||||
router.use(requireAuth, requireAdmin);
|
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) => {
|
router.get('/', async (req, res) => {
|
||||||
const { company_id } = req.query;
|
const { company_id } = req.query;
|
||||||
const params = [];
|
if (company_id && !isId(company_id)) return res.status(400).json({ error: 'Société invalide' });
|
||||||
let where = '';
|
|
||||||
if (company_id) {
|
|
||||||
params.push(company_id);
|
|
||||||
where = `WHERE u.company_id = $${params.length}`;
|
|
||||||
}
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT u.id, u.full_name, u.email, u.role, u.company_id, u.active, u.created_at,
|
`SELECT u.id, u.full_name, u.email, u.role, u.company_id, u.active, u.created_at,
|
||||||
c.name AS company_name
|
c.name AS company_name
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN companies c ON c.id = u.company_id
|
LEFT JOIN companies c ON c.id = u.company_id
|
||||||
${where}
|
${company_id ? 'WHERE u.company_id = $1' : ''}
|
||||||
ORDER BY u.full_name`,
|
ORDER BY u.full_name`,
|
||||||
params
|
company_id ? [company_id] : []
|
||||||
);
|
);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
const { full_name, email, password, role, company_id } = req.body || {};
|
const body = req.body || {};
|
||||||
if (!full_name || !email || !password || !role) {
|
const err = profileError(body) || passwordError(body.password);
|
||||||
return res.status(400).json({ error: 'Champs requis manquants' });
|
if (err) return res.status(400).json({ error: err });
|
||||||
}
|
const { full_name, email, password, role, company_id } = body;
|
||||||
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' });
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const hash = await bcrypt.hash(password, 10);
|
const hash = await bcrypt.hash(password, 10);
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`INSERT INTO users (full_name, email, password_hash, role, company_id, active)
|
`INSERT INTO users (full_name, email, password_hash, role, company_id, active)
|
||||||
VALUES ($1, $2, $3, $4, $5, true)
|
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]
|
[full_name.trim(), email.trim().toLowerCase(), hash, role, role === 'admin' ? null : company_id]
|
||||||
);
|
);
|
||||||
res.status(201).json(rows[0]);
|
res.status(201).json(rows[0]);
|
||||||
} catch (err) {
|
} catch (e) {
|
||||||
if (err.code === '23505') return res.status(409).json({ error: 'Cet email existe déjà' });
|
dbError(e, res);
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id', async (req, res) => {
|
router.put('/:id', async (req, res) => {
|
||||||
const { full_name, email, role, company_id, active } = req.body || {};
|
const body = req.body || {};
|
||||||
if (!full_name || !email || !role) {
|
const err = profileError(body);
|
||||||
return res.status(400).json({ error: 'Champs requis manquants' });
|
if (err) return res.status(400).json({ error: err });
|
||||||
}
|
const { full_name, email, role, company_id } = body;
|
||||||
if (!['admin', 'cadre'].includes(role)) {
|
const active = body.active !== false;
|
||||||
return res.status(400).json({ error: 'Rôle invalide' });
|
// An admin locking themselves out (or the last admin disappearing) can only
|
||||||
}
|
// be undone directly in the database.
|
||||||
if (role === 'cadre' && !company_id) {
|
if (String(req.user.id) === req.params.id && (!active || role !== 'admin')) {
|
||||||
return res.status(400).json({ error: 'Une société doit être attribuée au cadre' });
|
return res.status(400).json({ error: 'Vous ne pouvez pas désactiver ni rétrograder votre propre compte' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`UPDATE users SET full_name = $1, email = $2, role = $3, company_id = $4, active = $5
|
`UPDATE users SET full_name = $1, email = $2, role = $3, company_id = $4, active = $5
|
||||||
WHERE id = $6
|
WHERE id = $6
|
||||||
RETURNING id, full_name, email, role, company_id, active, created_at`,
|
RETURNING ${USER_COLUMNS}`,
|
||||||
[
|
[full_name.trim(), email.trim().toLowerCase(), role, role === 'admin' ? null : company_id, active, req.params.id]
|
||||||
full_name.trim(),
|
|
||||||
email.trim().toLowerCase(),
|
|
||||||
role,
|
|
||||||
role === 'admin' ? null : company_id,
|
|
||||||
active !== false,
|
|
||||||
req.params.id,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
if (!rows[0]) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
if (!rows[0]) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
||||||
res.json(rows[0]);
|
res.json(rows[0]);
|
||||||
} catch (err) {
|
} catch (e) {
|
||||||
if (err.code === '23505') return res.status(409).json({ error: 'Cet email existe déjà' });
|
dbError(e, res);
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/:id/password', async (req, res) => {
|
router.put('/:id/password', async (req, res) => {
|
||||||
const { password } = req.body || {};
|
const { password } = req.body || {};
|
||||||
if (!password || password.length < 6) {
|
const err = passwordError(password);
|
||||||
return res.status(400).json({ error: 'Mot de passe trop court (6 caractères minimum)' });
|
if (err) return res.status(400).json({ error: err });
|
||||||
}
|
|
||||||
const hash = await bcrypt.hash(password, 10);
|
const hash = await bcrypt.hash(password, 10);
|
||||||
const { rowCount } = await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [
|
// Also ends that user's open sessions.
|
||||||
hash,
|
const { rowCount } = await db.query(
|
||||||
req.params.id,
|
'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 (!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 });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:id', async (req, res) => {
|
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' });
|
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' });
|
if (!rowCount) return res.status(404).json({ error: 'Utilisateur introuvable' });
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const db = require('../db');
|
const db = require('../db');
|
||||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||||
|
const { isId, parseYearMonth: parseYM, monthStart, idParam } = require('../utils/validate');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
router.param('companyId', idParam);
|
||||||
|
|
||||||
function parseYearMonth(req) {
|
function parseYearMonth(req) {
|
||||||
const year = parseInt(req.body?.year ?? req.query?.year, 10);
|
return parseYM(req.body?.year ?? req.query?.year, req.body?.month ?? req.query?.month);
|
||||||
const month = parseInt(req.body?.month ?? req.query?.month, 10);
|
|
||||||
if (!year || !month || month < 1 || month > 12) return null;
|
|
||||||
return { year, month };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cadre validates their own 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.
|
// 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;
|
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 { rows: userRows } = await db.query('SELECT id, company_id FROM users WHERE id = $1', [targetUserId]);
|
||||||
const target = userRows[0];
|
const target = userRows[0];
|
||||||
@@ -46,7 +46,7 @@ router.post('/cadre', async (req, res) => {
|
|||||||
router.post('/cadre/reopen', requireAdmin, async (req, res) => {
|
router.post('/cadre/reopen', requireAdmin, async (req, res) => {
|
||||||
const ym = parseYearMonth(req);
|
const ym = parseYearMonth(req);
|
||||||
const { user_id } = req.body || {};
|
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(
|
await db.query(
|
||||||
`INSERT INTO month_locks (user_id, year, month, cadre_validated, cadre_validated_at)
|
`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.
|
// Admin: status of every cadre in a company for a given month.
|
||||||
router.get('/company/:companyId', requireAdmin, async (req, res) => {
|
router.get('/company/:companyId', requireAdmin, async (req, res) => {
|
||||||
const year = parseInt(req.query.year, 10);
|
const ym = parseYearMonth(req);
|
||||||
const month = parseInt(req.query.month, 10);
|
if (!ym) return res.status(400).json({ error: 'year et month requis' });
|
||||||
|
const { year, month } = ym;
|
||||||
const companyId = req.params.companyId;
|
const companyId = req.params.companyId;
|
||||||
if (!year || !month) return res.status(400).json({ error: 'year et month requis' });
|
const start = monthStart(ym);
|
||||||
|
|
||||||
const start = `${year}-${String(month).padStart(2, '0')}-01`;
|
|
||||||
const { rows: cadres } = await db.query(
|
const { rows: cadres } = await db.query(
|
||||||
`SELECT u.id, u.full_name, u.email,
|
`SELECT u.id, u.full_name, u.email,
|
||||||
ml.cadre_validated, ml.cadre_validated_at,
|
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).
|
// Admin: one-shot overview of every company for a given month (dashboard).
|
||||||
router.get('/overview', requireAdmin, async (req, res) => {
|
router.get('/overview', requireAdmin, async (req, res) => {
|
||||||
const year = parseInt(req.query.year, 10);
|
const ym = parseYearMonth(req);
|
||||||
const month = parseInt(req.query.month, 10);
|
if (!ym) return res.status(400).json({ error: 'year et month requis' });
|
||||||
if (!year || !month) return res.status(400).json({ error: 'year et month requis' });
|
const { year, month } = ym;
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT c.id, c.name,
|
`SELECT c.id, c.name,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
+7
-3
@@ -9,7 +9,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- presencia_pgdata:/var/lib/postgresql/data
|
- presencia_pgdata:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "6543:5432"
|
# Reachable from this host only (psql, backups), not from the network.
|
||||||
|
- "127.0.0.1:6543:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U presencia"]
|
test: ["CMD-SHELL", "pg_isready -U presencia"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -29,13 +30,16 @@ services:
|
|||||||
PGUSER: presencia
|
PGUSER: presencia
|
||||||
PGPASSWORD: R4dsITu0vuigNQQwNlvj0agVyHupSyX
|
PGPASSWORD: R4dsITu0vuigNQQwNlvj0agVyHupSyX
|
||||||
PGDATABASE: presencia
|
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"
|
COOKIE_SECURE: "false"
|
||||||
ADMIN_EMAIL: michaelschal@ffest.fr
|
ADMIN_EMAIL: michaelschal@ffest.fr
|
||||||
ADMIN_PASSWORD: Lapîn2509
|
ADMIN_PASSWORD: Lapîn2509
|
||||||
ADMIN_NAME: Michael
|
ADMIN_NAME: Michael
|
||||||
ports:
|
ports:
|
||||||
- "4790:4790"
|
# The browser goes through the frontend; direct API access stays local.
|
||||||
|
- "127.0.0.1:4790:4790"
|
||||||
|
|
||||||
presencia-frontend:
|
presencia-frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
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
|
COPY public /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
@@ -4,6 +4,7 @@ server {
|
|||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
# Resolve the backend hostname at request time (via Docker's embedded DNS)
|
# Resolve the backend hostname at request time (via Docker's embedded DNS)
|
||||||
# instead of once at startup, so a recreated backend container is picked
|
# 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-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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
|
# 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.
|
# it only forces a revalidation, so unchanged files cost one 304.
|
||||||
location ~* \.(?:html|js|css)$ {
|
location ~* \.(?:html|js|css)$ {
|
||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "no-cache";
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Fonts are content-stable and large; let them sit in the browser cache.
|
# Fonts are content-stable and large; let them sit in the browser cache.
|
||||||
location ~* \.(?:woff2|woff|ttf)$ {
|
location ~* \.(?:woff2|woff|ttf)$ {
|
||||||
add_header Cache-Control "public, max-age=2592000";
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "no-cache";
|
||||||
|
include /etc/nginx/snippets/security-headers.conf;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,9 +52,12 @@ body { margin: 0; }
|
|||||||
/* ── mobile chrome ── */
|
/* ── mobile chrome ── */
|
||||||
.mobile-head { align-items: center; gap: 10px; margin-bottom: 14px; }
|
.mobile-head { align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||||
.mobile-title { font-size: 15px; font-weight: 500; margin-right: auto; }
|
.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 { 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 { flex-direction: column; gap: 3px; justify-content: center; text-align: center; padding: 6px 4px; font-size: 10px; }
|
.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 .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 ── */
|
/* ── generic stacks / heads ── */
|
||||||
.stack-20 { display: flex; flex-direction: column; gap: 20px; }
|
.stack-20 { display: flex; flex-direction: column; gap: 20px; }
|
||||||
@@ -72,6 +75,40 @@ body { margin: 0; }
|
|||||||
.cell-muted { color: var(--color-neutral-700); }
|
.cell-muted { color: var(--color-neutral-700); }
|
||||||
.cell-actions { text-align: right; white-space: nowrap; }
|
.cell-actions { text-align: right; white-space: nowrap; }
|
||||||
.empty-row td { color: var(--color-neutral-700); font-size: 13px; }
|
.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 ── */
|
/* ── dashboard ── */
|
||||||
.tile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; }
|
.tile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; }
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
<span id="user-meta" class="side-user-meta"></span>
|
<span id="user-meta" class="side-user-meta"></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="my-password-btn" class="navitem"><i class="ni-ico ph ph-lock-simple"></i><span>Mon mot de passe</span></button>
|
||||||
<button id="logout-btn" class="navitem"><i class="ni-ico ph ph-sign-out"></i><span>Déconnexion</span></button>
|
<button id="logout-btn" class="navitem"><i class="ni-ico ph ph-sign-out"></i><span>Déconnexion</span></button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -73,7 +74,8 @@
|
|||||||
<div class="mobile-only mobile-head">
|
<div class="mobile-only mobile-head">
|
||||||
<span class="brand-mark tiny"></span>
|
<span class="brand-mark tiny"></span>
|
||||||
<span id="screen-title" class="mobile-title"></span>
|
<span id="screen-title" class="mobile-title"></span>
|
||||||
<button id="logout-btn-mobile" class="chipbtn">Quitter</button>
|
<button id="my-password-btn-mobile" class="chipbtn chipbtn-icon" aria-label="Changer mon mot de passe" title="Changer mon mot de passe"><i class="ph ph-lock-simple"></i></button>
|
||||||
|
<button id="logout-btn-mobile" class="chipbtn" aria-label="Se déconnecter"><i class="ph ph-sign-out"></i>Quitter</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── DASHBOARD ─── -->
|
<!-- ─── DASHBOARD ─── -->
|
||||||
@@ -134,7 +136,7 @@
|
|||||||
<div id="view-options" class="toolrow-items"></div>
|
<div id="view-options" class="toolrow-items"></div>
|
||||||
<span class="toolrow-right">
|
<span class="toolrow-right">
|
||||||
<button id="fill-workdays-btn" class="btn btn-secondary">Remplir les jours ouvrés</button>
|
<button id="fill-workdays-btn" class="btn btn-secondary">Remplir les jours ouvrés</button>
|
||||||
<button id="clear-month-btn" class="btn btn-secondary">Tout effacer</button>
|
<button id="clear-month-btn" class="btn btn-secondary btn-danger-ghost">Tout effacer</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -282,11 +284,11 @@
|
|||||||
|
|
||||||
<!-- ══════════════ DIALOG ══════════════ -->
|
<!-- ══════════════ DIALOG ══════════════ -->
|
||||||
<div id="dialog-backdrop" class="dialog-backdrop" hidden>
|
<div id="dialog-backdrop" class="dialog-backdrop" hidden>
|
||||||
<div id="dialog" class="dialog"></div>
|
<div id="dialog" class="dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ══════════════ TOAST ══════════════ -->
|
<!-- ══════════════ TOAST ══════════════ -->
|
||||||
<div id="toast" class="toast" hidden></div>
|
<div id="toast" class="toast" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+245
-80
@@ -49,6 +49,12 @@
|
|||||||
});
|
});
|
||||||
let data = null;
|
let data = null;
|
||||||
try { data = await res.json(); } catch (e) { /* empty body */ }
|
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) {
|
if (!res.ok) {
|
||||||
const err = new Error((data && data.error) || `Erreur ${res.status}`);
|
const err = new Error((data && data.error) || `Erreur ${res.status}`);
|
||||||
err.status = res.status;
|
err.status = res.status;
|
||||||
@@ -62,12 +68,17 @@
|
|||||||
const el = $('toast');
|
const el = $('toast');
|
||||||
el.textContent = msg;
|
el.textContent = msg;
|
||||||
el.classList.toggle('is-error', kind === 'error');
|
el.classList.toggle('is-error', kind === 'error');
|
||||||
|
el.setAttribute('role', kind === 'error' ? 'alert' : 'status');
|
||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
clearTimeout(toastTimer);
|
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 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 monthLabel = () => `${MONTHS[state.m - 1]} ${state.y}`;
|
||||||
const iso = (d) => `${state.y}-${String(state.m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
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' })}`;
|
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 ────────────────────────────────────────────────────────────────
|
// ── auth ────────────────────────────────────────────────────────────────
|
||||||
async function boot() {
|
async function boot() {
|
||||||
let me;
|
let me;
|
||||||
@@ -127,8 +165,10 @@
|
|||||||
$('login-form').addEventListener('submit', async (e) => {
|
$('login-form').addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const errBox = $('login-error');
|
const errBox = $('login-error');
|
||||||
|
const btn = e.target.querySelector('[type="submit"]');
|
||||||
errBox.hidden = true;
|
errBox.hidden = true;
|
||||||
let user;
|
let user;
|
||||||
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
user = await api('/auth/login', {
|
user = await api('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -137,7 +177,10 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
errBox.textContent = err.message || 'Connexion impossible';
|
errBox.textContent = err.message || 'Connexion impossible';
|
||||||
errBox.hidden = false;
|
errBox.hidden = false;
|
||||||
|
$('login-password').select();
|
||||||
return;
|
return;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
// Past this point the login screen is gone, so its error box would be
|
// Past this point the login screen is gone, so its error box would be
|
||||||
// invisible: report anything that goes wrong as a toast instead.
|
// invisible: report anything that goes wrong as a toast instead.
|
||||||
@@ -154,29 +197,63 @@
|
|||||||
}
|
}
|
||||||
$('logout-btn').addEventListener('click', logout);
|
$('logout-btn').addEventListener('click', logout);
|
||||||
$('logout-btn-mobile').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(`
|
||||||
|
<h4 class="dialog-title" id="dialog-title">Changer mon mot de passe</h4>
|
||||||
|
<form id="mypw-form">
|
||||||
|
<div class="field"><label for="mypw-current">Mot de passe actuel</label>
|
||||||
|
<input class="input" id="mypw-current" type="password" required autocomplete="current-password" /></div>
|
||||||
|
<div class="field"><label for="mypw-new">Nouveau mot de passe</label>
|
||||||
|
<input class="input" id="mypw-new" type="password" required minlength="8" autocomplete="new-password" />
|
||||||
|
<span class="field-hint">8 caractères minimum. Vos autres sessions seront déconnectées.</span></div>
|
||||||
|
<div class="field"><label for="mypw-confirm">Confirmer</label>
|
||||||
|
<input class="input" id="mypw-confirm" type="password" required minlength="8" autocomplete="new-password" /></div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" data-close>Annuler</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Mettre à jour</button>
|
||||||
|
</div>
|
||||||
|
</form>`);
|
||||||
|
$('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 ──────────────────────────────────────────────────────────
|
// ── navigation ──────────────────────────────────────────────────────────
|
||||||
function navGroups() {
|
function navGroups() {
|
||||||
if (state.user.role === 'admin') {
|
if (state.user.role === 'admin') {
|
||||||
return [
|
return [
|
||||||
{ label: 'Pilotage', items: [
|
{ label: 'Pilotage', items: [
|
||||||
{ key: 'dashboard', label: 'Tableau de bord', icon: 'ph-squares-four' },
|
{ key: 'dashboard', label: 'Tableau de bord', short: 'Accueil', icon: 'ph-squares-four' },
|
||||||
{ key: 'validation', label: 'Validation & export', icon: 'ph-seal-check' },
|
{ key: 'validation', label: 'Validation & export', short: 'Validation', icon: 'ph-seal-check' },
|
||||||
] },
|
] },
|
||||||
{ label: 'Équipes', items: [
|
{ label: 'Équipes', items: [
|
||||||
{ key: 'plannings', label: 'Plannings des cadres', icon: 'ph-users-three' },
|
{ key: 'plannings', label: 'Plannings des cadres', short: 'Plannings', icon: 'ph-users-three' },
|
||||||
{ key: 'users', label: 'Utilisateurs', icon: 'ph-user-gear' },
|
{ key: 'users', label: 'Utilisateurs', short: 'Comptes', icon: 'ph-user-gear' },
|
||||||
] },
|
] },
|
||||||
{ label: 'Ma saisie', items: [{ key: 'planning', label: 'Mon planning', icon: 'ph-calendar-dots' }] },
|
{ 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', icon: 'ph-buildings' }] },
|
{ label: 'Configuration', items: [{ key: 'companies', label: 'Sociétés', short: 'Sociétés', icon: 'ph-buildings' }] },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ label: 'Mon mois', items: [
|
{ label: 'Mon mois', items: [
|
||||||
{ key: 'dashboard', label: 'Tableau de bord', icon: 'ph-squares-four' },
|
{ key: 'dashboard', label: 'Tableau de bord', short: 'Accueil', icon: 'ph-squares-four' },
|
||||||
{ key: 'planning', label: 'Mon planning', icon: 'ph-calendar-dots' },
|
{ 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 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>`).join('');
|
</div>`).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) => `
|
$('mobile-nav').innerHTML = flat.map((it) => `
|
||||||
<button class="navitem" data-nav="${it.key}" aria-current="false">
|
<button class="navitem" data-nav="${it.key}" aria-current="false" aria-label="${esc(it.label)}">
|
||||||
<i class="ph ${it.icon}"></i><span>${esc(it.label.split(' ')[0])}</span>
|
<i class="ph ${it.icon}"></i><span>${esc(it.short)}</span>
|
||||||
|
<span class="ni-badge" data-badge="${it.key}" hidden></span>
|
||||||
</button>`).join('');
|
</button>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setNavBadge(key, count) {
|
function setNavBadge(key, count) {
|
||||||
const el = document.querySelector(`[data-badge="${key}"]`);
|
document.querySelectorAll(`[data-badge="${key}"]`).forEach((el) => {
|
||||||
if (!el) return;
|
|
||||||
el.textContent = count || '';
|
el.textContent = count || '';
|
||||||
el.hidden = !count;
|
el.hidden = !count;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
@@ -368,7 +448,7 @@
|
|||||||
tb.innerHTML = '<tr class="empty-row"><td colspan="4">Aucune société. Créez-en une dans « Sociétés ».</td></tr>';
|
tb.innerHTML = '<tr class="empty-row"><td colspan="4">Aucune société. Créez-en une dans « Sociétés ».</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tb.innerHTML = companies.map((c) => {
|
fillTable(tb, companies.map((c) => {
|
||||||
const ready = c.activeCadres > 0 && c.validatedCadres === c.activeCadres;
|
const ready = c.activeCadres > 0 && c.validatedCadres === c.activeCadres;
|
||||||
const status = c.companyValidated ? 'Validée' : (ready ? 'Prête à valider' : 'En cours');
|
const status = c.companyValidated ? 'Validée' : (ready ? 'Prête à valider' : 'En cours');
|
||||||
const tagCls = c.companyValidated ? 'tag-accent' : (ready ? 'tag-outline' : 'tag-neutral');
|
const tagCls = c.companyValidated ? 'tag-accent' : (ready ? 'tag-outline' : 'tag-neutral');
|
||||||
@@ -378,7 +458,7 @@
|
|||||||
<td><span class="tag ${tagCls}">${status}</span></td>
|
<td><span class="tag ${tagCls}">${status}</span></td>
|
||||||
<td class="cell-actions"><button class="btn btn-ghost" data-open-company="${c.id}">Ouvrir</button></td>
|
<td class="cell-actions"><button class="btn btn-ghost" data-open-company="${c.id}">Ouvrir</button></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join(''));
|
||||||
tb.querySelectorAll('[data-open-company]').forEach((b) => {
|
tb.querySelectorAll('[data-open-company]').forEach((b) => {
|
||||||
b.addEventListener('click', () => { state.validationCompany = b.dataset.openCompany; go('validation'); });
|
b.addEventListener('click', () => { state.validationCompany = b.dataset.openCompany; go('validation'); });
|
||||||
});
|
});
|
||||||
@@ -401,10 +481,16 @@
|
|||||||
return c;
|
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() {
|
async function loadPlanning() {
|
||||||
|
const req = ++planningReq;
|
||||||
const qs = new URLSearchParams({ year: state.y, month: state.m });
|
const qs = new URLSearchParams({ year: state.y, month: state.m });
|
||||||
if (state.viewing) qs.set('user_id', state.viewing.id);
|
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();
|
renderPlanning();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,15 +622,38 @@
|
|||||||
openPop(half, date, period);
|
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) {
|
async function applyHalf(date, period, status) {
|
||||||
closePop();
|
closePop();
|
||||||
|
const cal = state.cal;
|
||||||
try {
|
try {
|
||||||
const body = status ? { date, period, status } : { date, period };
|
const body = status ? { date, period, status } : { date, period };
|
||||||
if (state.viewing) body.user_id = state.viewing.id;
|
if (state.viewing) body.user_id = state.viewing.id;
|
||||||
await api('/attendance', { method: status ? 'PUT' : 'DELETE', body });
|
await api('/attendance', { method: status ? 'PUT' : 'DELETE', body });
|
||||||
await loadPlanning();
|
patchEntries(cal, [{ date, period, status }]);
|
||||||
} catch (err) {
|
} 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 current = (state.cal.entries || []).find((e) => e.date === date && e.period === period);
|
||||||
const status = (current && current.status) || state.brush || 'present';
|
const status = (current && current.status) || state.brush || 'present';
|
||||||
closePop();
|
closePop();
|
||||||
|
const cal = state.cal;
|
||||||
try {
|
try {
|
||||||
const body = { entries: [{ date, period: 'AM', status }, { date, period: 'PM', status }] };
|
const body = { entries: [{ date, period: 'AM', status }, { date, period: 'PM', status }] };
|
||||||
if (state.viewing) body.user_id = state.viewing.id;
|
if (state.viewing) body.user_id = state.viewing.id;
|
||||||
await api('/attendance/bulk', { method: 'PUT', body });
|
await api('/attendance/bulk', { method: 'PUT', body });
|
||||||
await loadPlanning();
|
patchEntries(cal, body.entries);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, 'error');
|
writeFailed(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,20 +760,22 @@
|
|||||||
const body = { entries };
|
const body = { entries };
|
||||||
if (state.viewing) body.user_id = state.viewing.id;
|
if (state.viewing) body.user_id = state.viewing.id;
|
||||||
await api('/attendance/bulk', { method: 'PUT', body });
|
await api('/attendance/bulk', { method: 'PUT', body });
|
||||||
toast('Jours ouvrés vides remplis en « Présent »');
|
toast(`${entries.length} demi-journée(s) remplie(s) en « Présent »`);
|
||||||
await loadPlanning();
|
patchEntries(data, entries);
|
||||||
} catch (err) { toast(err.message, 'error'); }
|
} catch (err) { writeFailed(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
$('clear-month-btn').addEventListener('click', async () => {
|
$('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 {
|
try {
|
||||||
const body = { year: state.y, month: state.m };
|
const body = { year: state.y, month: state.m };
|
||||||
if (state.viewing) body.user_id = state.viewing.id;
|
if (state.viewing) body.user_id = state.viewing.id;
|
||||||
await api('/attendance/month', { method: 'DELETE', body });
|
await api('/attendance/month', { method: 'DELETE', body });
|
||||||
toast('Mois effacé');
|
toast('Mois effacé');
|
||||||
await loadPlanning();
|
patchEntries(cal, cal.entries.map((e) => ({ date: e.date, period: e.period, status: null })));
|
||||||
} catch (err) { toast(err.message, 'error'); }
|
} catch (err) { writeFailed(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── companies ───────────────────────────────────────────────────────────
|
// ── companies ───────────────────────────────────────────────────────────
|
||||||
@@ -685,14 +797,14 @@
|
|||||||
tb.innerHTML = '<tr class="empty-row"><td colspan="4">Aucune société pour l’instant.</td></tr>';
|
tb.innerHTML = '<tr class="empty-row"><td colspan="4">Aucune société pour l’instant.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tb.innerHTML = state.companies.map((c) => `<tr>
|
fillTable(tb, state.companies.map((c) => `<tr>
|
||||||
<td>${esc(c.name)}</td>
|
<td>${esc(c.name)}</td>
|
||||||
<td class="cell-muted">${c.active_cadre_count}</td>
|
<td class="cell-muted">${c.active_cadre_count}</td>
|
||||||
<td class="cell-muted">${new Date(c.created_at).toLocaleDateString('fr-FR')}</td>
|
<td class="cell-muted">${new Date(c.created_at).toLocaleDateString('fr-FR')}</td>
|
||||||
<td class="cell-actions">
|
<td class="cell-actions">
|
||||||
<button class="btn btn-ghost" data-co-rename="${c.id}">Renommer</button>
|
<button class="btn btn-ghost" data-co-rename="${c.id}">Renommer</button>
|
||||||
<button class="btn btn-ghost" data-co-delete="${c.id}">Supprimer</button>
|
<button class="btn btn-ghost btn-danger-ghost" data-co-delete="${c.id}">Supprimer</button>
|
||||||
</td></tr>`).join('');
|
</td></tr>`).join(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
$('companies-rows').addEventListener('click', async (e) => {
|
$('companies-rows').addEventListener('click', async (e) => {
|
||||||
@@ -714,25 +826,25 @@
|
|||||||
|
|
||||||
function companyDialog(company) {
|
function companyDialog(company) {
|
||||||
openDialog(`
|
openDialog(`
|
||||||
<h4 class="dialog-title">${company ? 'Renommer la société' : 'Nouvelle société'}</h4>
|
<h4 class="dialog-title" id="dialog-title">${company ? 'Renommer la société' : 'Nouvelle société'}</h4>
|
||||||
<form id="co-form">
|
<form id="co-form">
|
||||||
<div class="field"><label for="co-name">Nom</label>
|
<div class="field"><label for="co-name">Nom</label>
|
||||||
<input class="input" id="co-name" required value="${company ? esc(company.name) : ''}" /></div>
|
<input class="input" id="co-name" required maxlength="255" value="${company ? esc(company.name) : ''}" /></div>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button type="button" class="btn btn-secondary" data-close>Annuler</button>
|
<button type="button" class="btn btn-secondary" data-close>Annuler</button>
|
||||||
<button type="submit" class="btn btn-primary">${company ? 'Enregistrer' : 'Créer'}</button>
|
<button type="submit" class="btn btn-primary">${company ? 'Enregistrer' : 'Créer'}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>`);
|
</form>`);
|
||||||
$('co-form').addEventListener('submit', async (e) => {
|
$('co-form').addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const name = $('co-name').value.trim();
|
const name = $('co-name').value.trim();
|
||||||
try {
|
submitting(e.target, async () => {
|
||||||
if (company) await api(`/companies/${company.id}`, { method: 'PUT', body: { name } });
|
if (company) await api(`/companies/${company.id}`, { method: 'PUT', body: { name } });
|
||||||
else await api('/companies', { method: 'POST', body: { name } });
|
else await api('/companies', { method: 'POST', body: { name } });
|
||||||
closeDialog();
|
closeDialog();
|
||||||
toast('Société enregistrée');
|
toast('Société enregistrée');
|
||||||
await loadCompaniesView();
|
await loadCompaniesView();
|
||||||
} catch (err) { toast(err.message, 'error'); }
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,17 +857,28 @@
|
|||||||
await refreshUsers();
|
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() {
|
async function refreshUsers() {
|
||||||
const qs = state.filterCompany !== 'all' ? `?company_id=${state.filterCompany}` : '';
|
const qs = state.filterCompany !== 'all' ? `?company_id=${state.filterCompany}` : '';
|
||||||
state.users = await api(`/users${qs}`);
|
state.users = await api(`/users${qs}`);
|
||||||
const q = state.search.trim().toLowerCase();
|
renderUsers();
|
||||||
const rows = state.users.filter((u) => !q || `${u.full_name}${u.email}`.toLowerCase().includes(q));
|
}
|
||||||
|
|
||||||
|
function renderUsers() {
|
||||||
|
const rows = state.users.filter(matches);
|
||||||
const tb = $('users-rows');
|
const tb = $('users-rows');
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
tb.innerHTML = '<tr class="empty-row"><td colspan="6">Aucun utilisateur ne correspond.</td></tr>';
|
tb.innerHTML = `<tr class="empty-row"><td colspan="6">${state.users.length ? 'Aucun utilisateur ne correspond à ce filtre.' : 'Aucun utilisateur.'}</td></tr>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tb.innerHTML = rows.map((u) => `<tr>
|
const self = String(state.user.id);
|
||||||
|
fillTable(tb, rows.map((u) => `<tr>
|
||||||
<td>${esc(u.full_name)}</td>
|
<td>${esc(u.full_name)}</td>
|
||||||
<td class="cell-muted">${esc(u.email)}</td>
|
<td class="cell-muted">${esc(u.email)}</td>
|
||||||
<td><span class="tag ${u.role === 'admin' ? 'tag-accent' : 'tag-neutral'}">${u.role === 'admin' ? 'Admin' : 'Cadre'}</span></td>
|
<td><span class="tag ${u.role === 'admin' ? 'tag-accent' : 'tag-neutral'}">${u.role === 'admin' ? 'Admin' : 'Cadre'}</span></td>
|
||||||
@@ -764,12 +887,12 @@
|
|||||||
<td class="cell-actions">
|
<td class="cell-actions">
|
||||||
<button class="btn btn-ghost" data-u-edit="${u.id}">Modifier</button>
|
<button class="btn btn-ghost" data-u-edit="${u.id}">Modifier</button>
|
||||||
<button class="btn btn-ghost" data-u-pw="${u.id}">Mot de passe</button>
|
<button class="btn btn-ghost" data-u-pw="${u.id}">Mot de passe</button>
|
||||||
<button class="btn btn-ghost" data-u-toggle="${u.id}">${u.active ? 'Désactiver' : 'Activer'}</button>
|
${String(u.id) === self ? '' : `<button class="btn btn-ghost" data-u-toggle="${u.id}">${u.active ? 'Désactiver' : 'Activer'}</button>`}
|
||||||
</td></tr>`).join('');
|
</td></tr>`).join(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
$('us-co').addEventListener('change', (e) => { state.filterCompany = e.target.value; refreshUsers().catch((x) => toast(x.message, 'error')); });
|
$('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) => {
|
$('users-rows').addEventListener('click', async (e) => {
|
||||||
const ed = e.target.closest('[data-u-edit]');
|
const ed = e.target.closest('[data-u-edit]');
|
||||||
@@ -780,6 +903,7 @@
|
|||||||
if (pw) return passwordDialog(find(pw.dataset.uPw));
|
if (pw) return passwordDialog(find(pw.dataset.uPw));
|
||||||
if (tg) {
|
if (tg) {
|
||||||
const u = find(tg.dataset.uToggle);
|
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 {
|
try {
|
||||||
await api(`/users/${u.id}`, {
|
await api(`/users/${u.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -796,14 +920,15 @@
|
|||||||
function userDialog(user) {
|
function userDialog(user) {
|
||||||
const edit = !!user;
|
const edit = !!user;
|
||||||
openDialog(`
|
openDialog(`
|
||||||
<h4 class="dialog-title">${edit ? 'Modifier l’utilisateur' : 'Nouvel utilisateur'}</h4>
|
<h4 class="dialog-title" id="dialog-title">${edit ? 'Modifier l’utilisateur' : 'Nouvel utilisateur'}</h4>
|
||||||
<form id="u-form">
|
<form id="u-form">
|
||||||
<div class="field"><label for="u-name">Nom complet</label>
|
<div class="field"><label for="u-name">Nom complet</label>
|
||||||
<input class="input" id="u-name" required value="${edit ? esc(user.full_name) : ''}" /></div>
|
<input class="input" id="u-name" required value="${edit ? esc(user.full_name) : ''}" /></div>
|
||||||
<div class="field"><label for="u-email">E-mail</label>
|
<div class="field"><label for="u-email">E-mail</label>
|
||||||
<input class="input" id="u-email" type="email" required value="${edit ? esc(user.email) : ''}" /></div>
|
<input class="input" id="u-email" type="email" required value="${edit ? esc(user.email) : ''}" /></div>
|
||||||
${edit ? '' : `<div class="field"><label for="u-pass">Mot de passe</label>
|
${edit ? '' : `<div class="field"><label for="u-pass">Mot de passe</label>
|
||||||
<input class="input" id="u-pass" type="password" required minlength="6" /></div>`}
|
<input class="input" id="u-pass" type="password" required minlength="8" autocomplete="new-password" />
|
||||||
|
<span class="field-hint">8 caractères minimum. À transmettre à la personne.</span></div>`}
|
||||||
<div class="field"><label for="u-role">Rôle</label>
|
<div class="field"><label for="u-role">Rôle</label>
|
||||||
<select class="input" id="u-role">
|
<select class="input" id="u-role">
|
||||||
<option value="cadre" ${!edit || user.role === 'cadre' ? 'selected' : ''}>Cadre</option>
|
<option value="cadre" ${!edit || user.role === 'cadre' ? 'selected' : ''}>Cadre</option>
|
||||||
@@ -817,13 +942,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>`);
|
</form>`);
|
||||||
companyOptions($('u-co'), { selected: edit ? user.company_id : null });
|
companyOptions($('u-co'), { selected: edit ? user.company_id : null });
|
||||||
|
if (!state.companies.length) {
|
||||||
|
$('u-co').innerHTML = '<option value="">Aucune société — créez-en une d’abord</option>';
|
||||||
|
}
|
||||||
|
|
||||||
const role = $('u-role');
|
const role = $('u-role');
|
||||||
|
// The API refuses to demote your own account; don't offer it.
|
||||||
|
if (edit && String(user.id) === String(state.user.id)) role.disabled = true;
|
||||||
const toggleCo = () => { $('u-co-field').hidden = role.value === 'admin'; };
|
const toggleCo = () => { $('u-co-field').hidden = role.value === 'admin'; };
|
||||||
role.addEventListener('change', toggleCo);
|
role.addEventListener('change', toggleCo);
|
||||||
toggleCo();
|
toggleCo();
|
||||||
|
|
||||||
$('u-form').addEventListener('submit', async (e) => {
|
$('u-form').addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const body = {
|
const body = {
|
||||||
full_name: $('u-name').value.trim(),
|
full_name: $('u-name').value.trim(),
|
||||||
@@ -833,7 +963,7 @@
|
|||||||
};
|
};
|
||||||
if (!edit) body.password = $('u-pass').value;
|
if (!edit) body.password = $('u-pass').value;
|
||||||
else body.active = user.active;
|
else body.active = user.active;
|
||||||
try {
|
submitting(e.target, async () => {
|
||||||
if (edit) await api(`/users/${user.id}`, { method: 'PUT', body });
|
if (edit) await api(`/users/${user.id}`, { method: 'PUT', body });
|
||||||
else await api('/users', { method: 'POST', body });
|
else await api('/users', { method: 'POST', body });
|
||||||
closeDialog();
|
closeDialog();
|
||||||
@@ -847,29 +977,30 @@
|
|||||||
}
|
}
|
||||||
toast('Utilisateur enregistré');
|
toast('Utilisateur enregistré');
|
||||||
await refreshUsers();
|
await refreshUsers();
|
||||||
} catch (err) { toast(err.message, 'error'); }
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function passwordDialog(user) {
|
function passwordDialog(user) {
|
||||||
openDialog(`
|
openDialog(`
|
||||||
<h4 class="dialog-title">Nouveau mot de passe</h4>
|
<h4 class="dialog-title" id="dialog-title">Nouveau mot de passe</h4>
|
||||||
<p class="lede">Pour ${esc(user.full_name)}</p>
|
<p class="lede">Pour ${esc(user.full_name)}</p>
|
||||||
<form id="pw-form">
|
<form id="pw-form">
|
||||||
<div class="field"><label for="pw-new">Mot de passe</label>
|
<div class="field"><label for="pw-new">Mot de passe</label>
|
||||||
<input class="input" id="pw-new" type="password" required minlength="6" /></div>
|
<input class="input" id="pw-new" type="password" required minlength="8" autocomplete="new-password" />
|
||||||
|
<span class="field-hint">8 caractères minimum. Ses sessions ouvertes seront déconnectées.</span></div>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button type="button" class="btn btn-secondary" data-close>Annuler</button>
|
<button type="button" class="btn btn-secondary" data-close>Annuler</button>
|
||||||
<button type="submit" class="btn btn-primary">Mettre à jour</button>
|
<button type="submit" class="btn btn-primary">Mettre à jour</button>
|
||||||
</div>
|
</div>
|
||||||
</form>`);
|
</form>`);
|
||||||
$('pw-form').addEventListener('submit', async (e) => {
|
$('pw-form').addEventListener('submit', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
submitting(e.target, async () => {
|
||||||
await api(`/users/${user.id}/password`, { method: 'PUT', body: { password: $('pw-new').value } });
|
await api(`/users/${user.id}/password`, { method: 'PUT', body: { password: $('pw-new').value } });
|
||||||
closeDialog();
|
closeDialog();
|
||||||
toast('Mot de passe mis à jour');
|
toast('Mot de passe mis à jour');
|
||||||
} catch (err) { toast(err.message, 'error'); }
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,34 +1014,37 @@
|
|||||||
await refreshCadreRows();
|
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() {
|
async function refreshCadreRows() {
|
||||||
const qs = state.filterCompany !== 'all' ? `?company_id=${state.filterCompany}` : '';
|
const qs = state.filterCompany !== 'all' ? `?company_id=${state.filterCompany}` : '';
|
||||||
const users = await api(`/users${qs}`);
|
const cadres = (await api(`/users${qs}`)).filter((u) => u.role === 'cadre');
|
||||||
const q = state.search.trim().toLowerCase();
|
const byId = new Map();
|
||||||
const cadres = users.filter((u) => u.role === 'cadre' && (!q || `${u.full_name}${u.email}`.toLowerCase().includes(q)));
|
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}`)
|
||||||
// validation state per company, so each row can show Validé / En cours
|
.then((v) => v.cadres.forEach((c) => byId.set(c.id, c)))
|
||||||
const byCompany = new Map();
|
.catch(() => { /* company may have been removed */ })));
|
||||||
for (const cid of new Set(cadres.map((c) => c.company_id).filter(Boolean))) {
|
cadreData = { cadres, byId };
|
||||||
try {
|
renderCadreRows();
|
||||||
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 */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderCadreRows() {
|
||||||
|
const { byId } = cadreData;
|
||||||
|
const cadres = cadreData.cadres.filter(matches);
|
||||||
const box = $('cadre-rows');
|
const box = $('cadre-rows');
|
||||||
if (!cadres.length) {
|
if (!cadres.length) {
|
||||||
box.innerHTML = '<p class="lede">Aucun cadre ne correspond à ce filtre.</p>';
|
box.innerHTML = `<p class="lede">${cadreData.cadres.length ? 'Aucun cadre ne correspond à ce filtre.' : 'Aucun cadre. Créez des comptes « Cadre » dans Utilisateurs.'}</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const work = workdayHalves();
|
const work = workdayHalves();
|
||||||
box.innerHTML = cadres.map((u) => {
|
box.innerHTML = cadres.map((u) => {
|
||||||
const v = byCompany.get(u.id);
|
const v = byId.get(u.id);
|
||||||
const filled = v ? v.filled : 0;
|
const filled = v ? v.filled : 0;
|
||||||
const status = !u.active ? 'Inactif' : (v && v.cadreValidated ? 'Validé' : 'En cours');
|
const status = !u.active ? 'Inactif' : (v && v.cadreValidated ? 'Validé' : 'En cours');
|
||||||
const tagCls = !u.active ? 'tag-neutral' : (v && v.cadreValidated ? 'tag-accent' : 'tag-outline');
|
const tagCls = !u.active ? 'tag-neutral' : (v && v.cadreValidated ? 'tag-accent' : 'tag-outline');
|
||||||
return `<button class="rowbtn" data-cadre="${u.id}" data-name="${esc(u.full_name)}" data-co="${esc(u.company_name || '')}">
|
return `<button class="rowbtn" data-cadre="${u.id}" data-name="${esc(u.full_name)}" data-co="${esc(u.company_name || '')}">
|
||||||
<span class="avatar neutral">${initials(u.full_name)}</span>
|
<span class="avatar neutral">${esc(initials(u.full_name))}</span>
|
||||||
<span class="rowbtn-text"><span>${esc(u.full_name)}</span>
|
<span class="rowbtn-text"><span>${esc(u.full_name)}</span>
|
||||||
<span class="rowbtn-sub">${esc(u.company_name || '—')}</span></span>
|
<span class="rowbtn-sub">${esc(u.company_name || '—')}</span></span>
|
||||||
<span class="rowbtn-right">
|
<span class="rowbtn-right">
|
||||||
@@ -921,7 +1055,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$('pl-co').addEventListener('change', (e) => { state.filterCompany = e.target.value; refreshCadreRows().catch((x) => toast(x.message, 'error')); });
|
$('pl-co').addEventListener('change', (e) => { state.filterCompany = e.target.value; refreshCadreRows().catch((x) => toast(x.message, 'error')); });
|
||||||
$('pl-q').addEventListener('input', (e) => { state.search = e.target.value; refreshCadreRows().catch((x) => toast(x.message, 'error')); });
|
$('pl-q').addEventListener('input', (e) => { state.search = e.target.value; renderCadreRows(); });
|
||||||
|
|
||||||
$('cadre-rows').addEventListener('click', (e) => {
|
$('cadre-rows').addEventListener('click', (e) => {
|
||||||
const b = e.target.closest('[data-cadre]');
|
const b = e.target.closest('[data-cadre]');
|
||||||
@@ -988,14 +1122,14 @@
|
|||||||
tb.innerHTML = '<tr class="empty-row"><td colspan="4">Aucun cadre actif dans cette société.</td></tr>';
|
tb.innerHTML = '<tr class="empty-row"><td colspan="4">Aucun cadre actif dans cette société.</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tb.innerHTML = data.cadres.map((c) => `<tr>
|
fillTable(tb, data.cadres.map((c) => `<tr>
|
||||||
<td>${esc(c.fullName)}</td>
|
<td>${esc(c.fullName)}</td>
|
||||||
<td class="cell-muted">${c.filled}/${work}</td>
|
<td class="cell-muted">${c.filled}/${work}</td>
|
||||||
<td><span class="tag ${c.cadreValidated ? 'tag-accent' : 'tag-outline'}">${c.cadreValidated ? 'Validé' : 'En cours'}</span></td>
|
<td><span class="tag ${c.cadreValidated ? 'tag-accent' : 'tag-outline'}">${c.cadreValidated ? 'Validé' : 'En cours'}</span></td>
|
||||||
<td class="cell-actions">
|
<td class="cell-actions">
|
||||||
<button class="btn btn-ghost" data-v-open="${c.id}" data-name="${esc(c.fullName)}">Voir</button>
|
<button class="btn btn-ghost" data-v-open="${c.id}" data-name="${esc(c.fullName)}">Voir</button>
|
||||||
${c.cadreValidated && !data.companyValidated ? `<button class="btn btn-ghost" data-v-reopen="${c.id}">Réouvrir</button>` : ''}
|
${c.cadreValidated && !data.companyValidated ? `<button class="btn btn-ghost" data-v-reopen="${c.id}">Réouvrir</button>` : ''}
|
||||||
</td></tr>`).join('');
|
</td></tr>`).join(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
$('va-co').addEventListener('change', (e) => { state.validationCompany = e.target.value; refreshValidation().catch((x) => toast(x.message, 'error')); });
|
$('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'); }
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
$('export-pdf-btn').addEventListener('click', () => download('pdf'));
|
$('export-pdf-btn').addEventListener('click', (e) => download('pdf', e.currentTarget));
|
||||||
$('export-excel-btn').addEventListener('click', () => download('excel'));
|
$('export-excel-btn').addEventListener('click', (e) => download('excel', e.currentTarget));
|
||||||
function download(kind) {
|
|
||||||
if (!state.validationCompany) return;
|
// 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');
|
const a = document.createElement('a');
|
||||||
a.href = `/api/export/${kind}/${state.validationCompany}?year=${state.y}&month=${state.m}`;
|
a.href = url;
|
||||||
a.download = '';
|
a.download = name;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── history ─────────────────────────────────────────────────────────────
|
// ── history ─────────────────────────────────────────────────────────────
|
||||||
@@ -1056,11 +1211,11 @@
|
|||||||
tb.innerHTML = '<tr class="empty-row"><td colspan="7">Aucune saisie enregistrée pour le moment.</td></tr>';
|
tb.innerHTML = '<tr class="empty-row"><td colspan="7">Aucune saisie enregistrée pour le moment.</td></tr>';
|
||||||
return;
|
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 status = r.companyValidated ? 'Validé société' : (r.cadreValidated ? 'Validé cadre' : 'En cours');
|
||||||
const tagCls = r.companyValidated ? 'tag-accent' : (r.cadreValidated ? 'tag-outline' : 'tag-neutral');
|
const tagCls = r.companyValidated ? 'tag-accent' : (r.cadreValidated ? 'tag-outline' : 'tag-neutral');
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td style="text-transform:capitalize">${MONTHS[r.month - 1]} ${r.year}</td>
|
<td class="cell-capitalize">${MONTHS[r.month - 1]} ${r.year}</td>
|
||||||
<td class="cell-muted">${halfToDays(r.present)} j</td>
|
<td class="cell-muted">${halfToDays(r.present)} j</td>
|
||||||
<td class="cell-muted">${halfToDays(r.absent)} j</td>
|
<td class="cell-muted">${halfToDays(r.absent)} j</td>
|
||||||
<td class="cell-muted">${halfToDays(r.conge)} j</td>
|
<td class="cell-muted">${halfToDays(r.conge)} j</td>
|
||||||
@@ -1068,7 +1223,7 @@
|
|||||||
<td><span class="tag ${tagCls}">${status}</span></td>
|
<td><span class="tag ${tagCls}">${status}</span></td>
|
||||||
<td class="cell-actions"><button class="btn btn-ghost" data-hist="${r.year}-${r.month}">Ouvrir</button></td>
|
<td class="cell-actions"><button class="btn btn-ghost" data-hist="${r.year}-${r.month}">Ouvrir</button></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
$('history-rows').addEventListener('click', (e) => {
|
$('history-rows').addEventListener('click', (e) => {
|
||||||
@@ -1080,14 +1235,24 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── dialog ──────────────────────────────────────────────────────────────
|
// ── dialog ──────────────────────────────────────────────────────────────
|
||||||
|
let dialogOpener = null;
|
||||||
function openDialog(html) {
|
function openDialog(html) {
|
||||||
|
dialogOpener = document.activeElement;
|
||||||
$('dialog').innerHTML = html;
|
$('dialog').innerHTML = html;
|
||||||
$('dialog-backdrop').hidden = false;
|
$('dialog-backdrop').hidden = false;
|
||||||
|
const first = $('dialog').querySelector('input:not([disabled]), select:not([disabled])');
|
||||||
|
if (first) first.focus();
|
||||||
}
|
}
|
||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
$('dialog-backdrop').hidden = true;
|
$('dialog-backdrop').hidden = true;
|
||||||
$('dialog').innerHTML = '';
|
$('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) => {
|
$('dialog-backdrop').addEventListener('click', (e) => {
|
||||||
if (e.target === $('dialog-backdrop') || e.target.closest('[data-close]')) closeDialog();
|
if (e.target === $('dialog-backdrop') || e.target.closest('[data-close]')) closeDialog();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in new issue
Block a user