feat: ajout changement de mot de passe + bind mounts persistants + credentials en variables d'env
This commit is contained in:
+10
-31
@@ -1,33 +1,12 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────
|
# Copier ce fichier en .env et remplir les valeurs réelles
|
||||||
# NotesFrais — variables d'environnement (docker-compose)
|
# NE PAS commiter .env dans git — ajouter .env au .gitignore
|
||||||
# Copier ce fichier en .env puis remplir les valeurs CHANGE_ME
|
# Ces variables doivent être saisies dans Coolify > Environment Variables
|
||||||
# ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
# ── Domaine public (sans https://) ───────────────────────────────
|
DB_PASSWORD=<mot_de_passe_postgresql_fort>
|
||||||
# Utilisé par Coolify/Traefik pour le routage HTTPS.
|
JWT_SECRET=<secret_jwt_long_et_aleatoire>
|
||||||
DOMAIN=frais.domench.fr
|
APP_SECRET=<secret_applicatif_long_et_aleatoire>
|
||||||
|
|
||||||
# ── Base de données PostgreSQL ───────────────────────────────────
|
GAEL_EMAIL=waltergael@1dotech.com
|
||||||
# Mot de passe du compte notesfrais dans PostgreSQL.
|
GAEL_PASSWORD=<mot_de_passe_gael>
|
||||||
# Générer : openssl rand -hex 32
|
GREG_EMAIL=waltergreg@1dotech.com
|
||||||
DB_PASSWORD=CHANGE_ME
|
GREG_PASSWORD=<mot_de_passe_greg>
|
||||||
|
|
||||||
# ── 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
|
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ const loginSchema = z.object({
|
|||||||
password: z.string().min(1, 'Mot de passe requis'),
|
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 ─────────────────────────────────────────────────
|
// ─── Routes ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -131,4 +140,36 @@ router.get('/me', requireAuth, async (req: AuthRequest, res: Response, next: exp
|
|||||||
} catch (err) { next(err); }
|
} 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<void> => {
|
||||||
|
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;
|
export default router;
|
||||||
|
|||||||
+15
-17
@@ -6,9 +6,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: notesfrais
|
POSTGRES_DB: notesfrais
|
||||||
POSTGRES_USER: notesfrais
|
POSTGRES_USER: notesfrais
|
||||||
POSTGRES_PASSWORD: 9a3dabd70bb1e09f09962a95bdaffbeacdc56eeee029334b
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata4:/var/lib/postgresql/data
|
- /opt/notesfrais/pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U notesfrais -d notesfrais"]
|
test: ["CMD-SHELL", "pg_isready -U notesfrais -d notesfrais"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -24,19 +24,19 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
DATABASE_URL: postgresql://notesfrais:9a3dabd70bb1e09f09962a95bdaffbeacdc56eeee029334b@db:5432/notesfrais
|
DATABASE_URL: postgresql://notesfrais:${DB_PASSWORD}@db:5432/notesfrais
|
||||||
JWT_SECRET: f1bec20689d176182a33b6904a236d9c207322fb5886e6599812bfbc236bd95bff4d1a05edb273a6ccdb6e06004e059241f7937ef91dee08b92fc25f0ea767c1
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
APP_SECRET: bbec693632ddd25adeefaddfa64a3e8e1245a97f530cb492f1d3f496ae3a1936
|
APP_SECRET: ${APP_SECRET}
|
||||||
UPLOADS_DIR: /app/uploads
|
UPLOADS_DIR: /app/uploads
|
||||||
FRONTEND_URL: https://frais.domench.fr
|
FRONTEND_URL: https://frais.domench.fr
|
||||||
GAEL_EMAIL: waltergael@1dotech.com
|
GAEL_EMAIL: ${GAEL_EMAIL}
|
||||||
GAEL_PASSWORD: Changeme123!
|
GAEL_PASSWORD: ${GAEL_PASSWORD}
|
||||||
GREG_EMAIL: waltergreg@1dotech.com
|
GREG_EMAIL: ${GREG_EMAIL}
|
||||||
GREG_PASSWORD: Changeme123!
|
GREG_PASSWORD: ${GREG_PASSWORD}
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/app/uploads
|
- /opt/notesfrais/uploads:/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -79,6 +79,4 @@ networks:
|
|||||||
coolify:
|
coolify:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
# Pas de volumes nommés — bind mounts définis directement dans chaque service
|
||||||
pgdata4:
|
|
||||||
uploads:
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Page Paramètres — 4 sections :
|
* Page Paramètres — 5 sections :
|
||||||
* 1. SMTP (config email par utilisateur)
|
* 1. Mot de passe
|
||||||
* 2. Sociétés (nom + email de remboursement)
|
* 2. SMTP (config email par utilisateur)
|
||||||
* 3. Catégories
|
* 3. Sociétés (nom + email de remboursement)
|
||||||
* 4. Microsoft 365 / SharePoint (Azure App Registration + fichier Excel commun)
|
* 4. Catégories
|
||||||
|
* 5. Microsoft 365 / SharePoint (Azure App Registration + fichier Excel commun)
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
@@ -12,6 +13,109 @@ import { useRef } from 'react';
|
|||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
import type { Company, Category, SmtpConfig, AppSettings, Contact } from '../types';
|
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<Record<string, string>>({});
|
||||||
|
|
||||||
|
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<string, string> = {};
|
||||||
|
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 (
|
||||||
|
<div className="card">
|
||||||
|
<button onClick={() => setOpen(!open)}
|
||||||
|
className="flex items-center justify-between w-full p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-rose-50 flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-rose-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-semibold text-sm text-gray-900">Mot de passe</p>
|
||||||
|
<p className="text-xs text-gray-400">Modifier votre mot de passe de connexion</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg className={`w-5 h-5 text-gray-300 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<form onSubmit={handleSubmit} className="px-4 pb-4 space-y-3 border-t border-gray-50 pt-3">
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Mot de passe actuel</label>
|
||||||
|
<input
|
||||||
|
className={`form-input ${errors.currentPassword ? 'border-red-400' : ''}`}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={form.currentPassword}
|
||||||
|
onChange={e => setForm(f => ({ ...f, currentPassword: e.target.value }))}
|
||||||
|
/>
|
||||||
|
{errors.currentPassword && <p className="text-xs text-red-500 mt-1">{errors.currentPassword}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Nouveau mot de passe</label>
|
||||||
|
<input
|
||||||
|
className={`form-input ${errors.newPassword ? 'border-red-400' : ''}`}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="8 caractères minimum"
|
||||||
|
value={form.newPassword}
|
||||||
|
onChange={e => setForm(f => ({ ...f, newPassword: e.target.value }))}
|
||||||
|
/>
|
||||||
|
{errors.newPassword && <p className="text-xs text-red-500 mt-1">{errors.newPassword}</p>}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Confirmer le nouveau mot de passe</label>
|
||||||
|
<input
|
||||||
|
className={`form-input ${errors.confirmPassword ? 'border-red-400' : ''}`}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={form.confirmPassword}
|
||||||
|
onChange={e => setForm(f => ({ ...f, confirmPassword: e.target.value }))}
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && <p className="text-xs text-red-500 mt-1">{errors.confirmPassword}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="pt-1">
|
||||||
|
<button type="submit" disabled={change.isPending} className="btn-primary py-2 text-sm">
|
||||||
|
{change.isPending ? 'Modification…' : 'Modifier le mot de passe'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Section SMTP ────────────────────────────────────────────
|
// ─── Section SMTP ────────────────────────────────────────────
|
||||||
|
|
||||||
function SmtpSection() {
|
function SmtpSection() {
|
||||||
@@ -564,6 +668,7 @@ export default function Settings() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-bold text-gray-900">Paramètres</h2>
|
<h2 className="text-xl font-bold text-gray-900">Paramètres</h2>
|
||||||
|
<PasswordSection />
|
||||||
<SmtpSection />
|
<SmtpSection />
|
||||||
<CompaniesSection />
|
<CompaniesSection />
|
||||||
<CategoriesSection />
|
<CategoriesSection />
|
||||||
|
|||||||
Reference in New Issue
Block a user