diff --git a/.env.example b/.env.example index 79aa779..5b3be7c 100644 --- a/.env.example +++ b/.env.example @@ -1,33 +1,12 @@ -# ───────────────────────────────────────────────────────────────── -# NotesFrais — variables d'environnement (docker-compose) -# Copier ce fichier en .env puis remplir les valeurs CHANGE_ME -# ───────────────────────────────────────────────────────────────── +# Copier ce fichier en .env et remplir les valeurs réelles +# NE PAS commiter .env dans git — ajouter .env au .gitignore +# Ces variables doivent être saisies dans Coolify > Environment Variables -# ── Domaine public (sans https://) ─────────────────────────────── -# Utilisé par Coolify/Traefik pour le routage HTTPS. -DOMAIN=frais.domench.fr +DB_PASSWORD= +JWT_SECRET= +APP_SECRET= -# ── Base de données PostgreSQL ─────────────────────────────────── -# Mot de passe du compte notesfrais dans PostgreSQL. -# Générer : openssl rand -hex 32 -DB_PASSWORD=CHANGE_ME - -# ── JWT (authentification) ─────────────────────────────────────── -# Secret pour signer les tokens d'accès (HS256). -# Générer : openssl rand -hex 64 -JWT_SECRET=CHANGE_ME - -# ── Chiffrement AES-256-GCM (secrets stockés en base) ─────────── -# Clé de 32 octets hex pour chiffrer les mots de passe SMTP -# et les identifiants Microsoft Graph enregistrés en BDD. -# Générer : openssl rand -hex 32 -APP_SECRET=CHANGE_ME - -# ── Initialisation des comptes (script init-users uniquement) ──── -# Ces variables ne sont lues qu'une seule fois lors du premier -# lancement de npm run init-users (ou docker exec … node dist/scripts/init-users.js). -# Elles peuvent être supprimées ensuite. -GREG_EMAIL=greg@domench.fr -GREG_PASSWORD=CHANGE_ME -GAEL_EMAIL=gael@domench.fr -GAEL_PASSWORD=CHANGE_ME +GAEL_EMAIL=waltergael@1dotech.com +GAEL_PASSWORD= +GREG_EMAIL=waltergreg@1dotech.com +GREG_PASSWORD= diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 1f3e9de..8d7b0c6 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -31,6 +31,15 @@ const loginSchema = z.object({ password: z.string().min(1, 'Mot de passe requis'), }); +const changePasswordSchema = z.object({ + currentPassword: z.string().min(1, 'Mot de passe actuel requis'), + newPassword: z.string().min(8, 'Le nouveau mot de passe doit faire au moins 8 caractères'), + confirmPassword: z.string().min(1, 'Confirmation requise'), +}).refine(data => data.newPassword === data.confirmPassword, { + message: 'Les mots de passe ne correspondent pas', + path: ['confirmPassword'], +}); + // ─── Routes ───────────────────────────────────────────────── /** @@ -131,4 +140,36 @@ router.get('/me', requireAuth, async (req: AuthRequest, res: Response, next: exp } catch (err) { next(err); } }); +/** + * PATCH /api/auth/password + * Body: { currentPassword, newPassword, confirmPassword } + * Permet à l'utilisateur connecté de changer son mot de passe. + */ +router.patch('/password', requireAuth, validate(changePasswordSchema), async (req: AuthRequest, res: Response, next: express.NextFunction): Promise => { + try { + const { currentPassword, newPassword } = req.body; + + // Récupérer le hash actuel + const result = await db.query('SELECT password_hash FROM users WHERE id = $1', [req.user!.id]); + const user = result.rows[0]; + + if (!user || !(await bcrypt.compare(currentPassword, user.password_hash))) { + res.status(401).json({ error: 'Mot de passe actuel incorrect' }); + return; + } + + // Hacher et sauvegarder le nouveau mot de passe + const newHash = await bcrypt.hash(newPassword, 12); + await db.query( + 'UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2', + [newHash, req.user!.id] + ); + + // Révoquer tous les refresh tokens (force reconnexion sur les autres appareils) + await db.query('DELETE FROM refresh_tokens WHERE user_id = $1', [req.user!.id]); + + res.json({ success: true }); + } catch (err) { next(err); } +}); + export default router; diff --git a/docker-compose.yml b/docker-compose.yml index 319d73a..efa62f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,9 +6,9 @@ services: environment: POSTGRES_DB: notesfrais POSTGRES_USER: notesfrais - POSTGRES_PASSWORD: 9a3dabd70bb1e09f09962a95bdaffbeacdc56eeee029334b + POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - - pgdata4:/var/lib/postgresql/data + - /opt/notesfrais/pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U notesfrais -d notesfrais"] interval: 5s @@ -24,19 +24,19 @@ services: context: ./backend dockerfile: Dockerfile environment: - NODE_ENV: production - PORT: 3001 - DATABASE_URL: postgresql://notesfrais:9a3dabd70bb1e09f09962a95bdaffbeacdc56eeee029334b@db:5432/notesfrais - JWT_SECRET: f1bec20689d176182a33b6904a236d9c207322fb5886e6599812bfbc236bd95bff4d1a05edb273a6ccdb6e06004e059241f7937ef91dee08b92fc25f0ea767c1 - APP_SECRET: bbec693632ddd25adeefaddfa64a3e8e1245a97f530cb492f1d3f496ae3a1936 - UPLOADS_DIR: /app/uploads - FRONTEND_URL: https://frais.domench.fr - GAEL_EMAIL: waltergael@1dotech.com - GAEL_PASSWORD: Changeme123! - GREG_EMAIL: waltergreg@1dotech.com - GREG_PASSWORD: Changeme123! + NODE_ENV: production + PORT: 3001 + DATABASE_URL: postgresql://notesfrais:${DB_PASSWORD}@db:5432/notesfrais + JWT_SECRET: ${JWT_SECRET} + APP_SECRET: ${APP_SECRET} + UPLOADS_DIR: /app/uploads + FRONTEND_URL: https://frais.domench.fr + GAEL_EMAIL: ${GAEL_EMAIL} + GAEL_PASSWORD: ${GAEL_PASSWORD} + GREG_EMAIL: ${GREG_EMAIL} + GREG_PASSWORD: ${GREG_PASSWORD} volumes: - - uploads:/app/uploads + - /opt/notesfrais/uploads:/app/uploads depends_on: db: condition: service_healthy @@ -79,6 +79,4 @@ networks: coolify: external: true -volumes: - pgdata4: - uploads: +# Pas de volumes nommés — bind mounts définis directement dans chaque service diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 2f41595..9d63b4f 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,9 +1,10 @@ /** - * Page Paramètres — 4 sections : - * 1. SMTP (config email par utilisateur) - * 2. Sociétés (nom + email de remboursement) - * 3. Catégories - * 4. Microsoft 365 / SharePoint (Azure App Registration + fichier Excel commun) + * Page Paramètres — 5 sections : + * 1. Mot de passe + * 2. SMTP (config email par utilisateur) + * 3. Sociétés (nom + email de remboursement) + * 4. Catégories + * 5. Microsoft 365 / SharePoint (Azure App Registration + fichier Excel commun) */ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -12,6 +13,109 @@ import { useRef } from 'react'; import api from '../api/client'; import type { Company, Category, SmtpConfig, AppSettings, Contact } from '../types'; +// ─── Section Mot de passe ──────────────────────────────────── + +function PasswordSection() { + const [open, setOpen] = useState(false); + const [form, setForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' }); + const [errors, setErrors] = useState>({}); + + const change = useMutation({ + mutationFn: (payload: typeof form) => api.patch('/auth/password', payload), + onSuccess: () => { + toast.success('Mot de passe modifié'); + setForm({ currentPassword: '', newPassword: '', confirmPassword: '' }); + setErrors({}); + setOpen(false); + }, + onError: (e: any) => { + const msg = e.response?.data?.error ?? 'Erreur'; + toast.error(msg); + }, + }); + + function validate() { + const errs: Record = {}; + if (!form.currentPassword) errs.currentPassword = 'Requis'; + if (form.newPassword.length < 8) errs.newPassword = 'Au moins 8 caractères'; + if (form.newPassword !== form.confirmPassword) errs.confirmPassword = 'Les mots de passe ne correspondent pas'; + setErrors(errs); + return Object.keys(errs).length === 0; + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (validate()) change.mutate(form); + } + + return ( +
+ + + {open && ( +
+
+ + setForm(f => ({ ...f, currentPassword: e.target.value }))} + /> + {errors.currentPassword &&

{errors.currentPassword}

} +
+
+ + setForm(f => ({ ...f, newPassword: e.target.value }))} + /> + {errors.newPassword &&

{errors.newPassword}

} +
+
+ + setForm(f => ({ ...f, confirmPassword: e.target.value }))} + /> + {errors.confirmPassword &&

{errors.confirmPassword}

} +
+
+ +
+
+ )} +
+ ); +} + // ─── Section SMTP ──────────────────────────────────────────── function SmtpSection() { @@ -564,6 +668,7 @@ export default function Settings() { return (

Paramètres

+