From 032a370e7da52385c055bc589dcd2d1c6dcfbc43 Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 15 Jun 2026 23:03:37 +0200 Subject: [PATCH] =?UTF-8?q?Maj=20Section=20G=C3=A9n=C3=A9ral?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db/index.js | 14 ++ backend/src/routes/general.js | 73 +++++++ backend/src/routes/smtp.js | 132 ++++++++++++ backend/src/server.js | 13 +- frontend/src/App.jsx | 11 +- frontend/src/components/PasswordStrength.jsx | 11 +- frontend/src/pages/Admin.jsx | 4 + frontend/src/pages/InvitationRegister.jsx | 8 +- frontend/src/pages/Login.jsx | 12 +- frontend/src/pages/MonCompte.jsx | 9 +- frontend/src/pages/Register.jsx | 6 +- frontend/src/pages/ResetPassword.jsx | 6 +- frontend/src/pages/admin/GeneralSection.jsx | 208 +++++++++++++++++++ frontend/src/pages/admin/SmtpSection.jsx | 31 --- frontend/src/pages/admin/UsersSection.jsx | 11 +- 15 files changed, 492 insertions(+), 57 deletions(-) create mode 100644 backend/src/routes/general.js create mode 100644 backend/src/routes/smtp.js create mode 100644 frontend/src/pages/admin/GeneralSection.jsx diff --git a/backend/src/db/index.js b/backend/src/db/index.js index bdcc6fe..cdfaae4 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -1858,4 +1858,18 @@ console.log('[DB] Migrations 2FA OK'); } + +// ── Migration : colonnes paramètres généraux dans smtp_config ───────────── +{ + const cols = db.prepare("PRAGMA table_info(smtp_config)").all().map(c => c.name); + if (!cols.includes('allow_registration')) { + db.exec("ALTER TABLE smtp_config ADD COLUMN allow_registration INTEGER NOT NULL DEFAULT 1"); + console.log('[DB] Colonne smtp_config.allow_registration ajoutée'); + } + if (!cols.includes('min_password_length')) { + db.exec("ALTER TABLE smtp_config ADD COLUMN min_password_length INTEGER NOT NULL DEFAULT 8"); + console.log('[DB] Colonne smtp_config.min_password_length ajoutée'); + } +} + export default db; diff --git a/backend/src/routes/general.js b/backend/src/routes/general.js new file mode 100644 index 0000000..9db8a9e --- /dev/null +++ b/backend/src/routes/general.js @@ -0,0 +1,73 @@ +/** + * /api/admin/general — Paramètres généraux de l'application + * Protégé par requireAuth + requireAdmin (appliqués dans server.js). + * + * GET / — retourne les 4 champs généraux + * PATCH / — met à jour un ou plusieurs champs + * + * Route publique complémentaire : /api/app-info (server.js) expose + * allowRegistration et minPasswordLength sans auth. + */ + +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; + +const router = Router(); + +function ensureRow() { + const exists = db.prepare('SELECT id FROM smtp_config WHERE id = 1').get(); + if (!exists) { + db.prepare(` + INSERT INTO smtp_config (id, enabled, host, port, secure, email, username, password, + allow_unauth, app_name, app_url, allow_registration, min_password_length) + VALUES (1, 0, '', 587, 0, '', '', '', 0, 'Crowdlending Tracker', '', 1, 8) + `).run(); + } +} + +router.get('/', (_req, res, next) => { + try { + ensureRow(); + const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get(); + res.json({ + appName: row.app_name || 'Crowdlending Tracker', + appUrl: row.app_url || '', + allowRegistration: row.allow_registration !== 0, + minPasswordLength: row.min_password_length || 8, + }); + } catch (e) { next(e); } +}); + +const PatchSchema = z.object({ + appName: z.string().min(1).max(100).optional(), + appUrl: z.string().max(500).optional(), + allowRegistration: z.boolean().optional(), + minPasswordLength: z.number().int().min(6).max(64).optional(), +}); + +router.patch('/', (req, res, next) => { + try { + ensureRow(); + const body = PatchSchema.parse(req.body); + const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get(); + + db.prepare(` + UPDATE smtp_config SET + app_name = ?, + app_url = ?, + allow_registration = ?, + min_password_length = ? + WHERE id = 1 + `).run( + body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'), + body.appUrl !== undefined ? body.appUrl : (row.app_url || ''), + body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0), + body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8), + ); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +export default router; diff --git a/backend/src/routes/smtp.js b/backend/src/routes/smtp.js new file mode 100644 index 0000000..a1e7411 --- /dev/null +++ b/backend/src/routes/smtp.js @@ -0,0 +1,132 @@ +/** + * /api/admin/smtp — Gestion de la configuration SMTP + * Protégé par requireAuth + requireAdmin (appliqués dans server.js). + */ + +import { Router } from 'express'; +import { z } from 'zod'; +import db from '../db/index.js'; +import { HttpError } from '../middleware/errorHandler.js'; +import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js'; + +const router = Router(); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function ensureRow() { + const exists = db.prepare('SELECT id FROM smtp_config WHERE id = 1').get(); + if (!exists) { + db.prepare(` + INSERT INTO smtp_config (id, enabled, host, port, secure, email, username, password, + allow_unauth, app_name, app_url, allow_registration, min_password_length) + VALUES (1, 0, '', 587, 0, '', '', '', 0, 'Crowdlending Tracker', '', 1, 8) + `).run(); + } +} + +// ── GET /admin/smtp ───────────────────────────────────────────────────────── + +router.get('/', (_req, res, next) => { + try { + ensureRow(); + const row = db.prepare('SELECT * FROM smtp_config WHERE id = 1').get(); + res.json({ + enabled: !!row.enabled, + host: row.host || '', + port: row.port || 587, + secure: !!row.secure, + email: row.email || '', + username: row.username || '', + hasPassword: !!(row.password), + allowUnauth: !!row.allow_unauth, + appName: row.app_name || 'Crowdlending Tracker', + appUrl: row.app_url || '', + allowRegistration: row.allow_registration !== 0, + minPasswordLength: row.min_password_length || 8, + }); + } catch (e) { next(e); } +}); + +// ── PUT /admin/smtp ───────────────────────────────────────────────────────── + +const SmtpSchema = z.object({ + enabled: z.boolean().optional(), + host: z.string().optional(), + port: z.number().int().min(1).max(65535).optional(), + secure: z.boolean().optional(), + email: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), + allowUnauth: z.boolean().optional(), + appName: z.string().max(100).optional(), + appUrl: z.string().max(500).optional(), +}); + +router.put('/', (req, res, next) => { + try { + ensureRow(); + const body = SmtpSchema.parse(req.body); + const row = db.prepare('SELECT * FROM smtp_config WHERE id = 1').get(); + + const updates = { + enabled: body.enabled !== undefined ? (body.enabled ? 1 : 0) : row.enabled, + host: body.host !== undefined ? body.host : row.host, + port: body.port !== undefined ? body.port : row.port, + secure: body.secure !== undefined ? (body.secure ? 1 : 0) : row.secure, + email: body.email !== undefined ? body.email : row.email, + username: body.username !== undefined ? body.username : row.username, + password: body.password !== undefined ? body.password : row.password, + allow_unauth: body.allowUnauth !== undefined ? (body.allowUnauth ? 1 : 0) : row.allow_unauth, + app_name: body.appName !== undefined ? body.appName : row.app_name, + app_url: body.appUrl !== undefined ? body.appUrl : row.app_url, + }; + + db.prepare(` + UPDATE smtp_config SET + enabled=?, host=?, port=?, secure=?, email=?, username=?, password=?, + allow_unauth=?, app_name=?, app_url=? + WHERE id=1 + `).run( + updates.enabled, updates.host, updates.port, updates.secure, + updates.email, updates.username, updates.password, updates.allow_unauth, + updates.app_name, updates.app_url, + ); + + res.json({ ok: true }); + } catch (e) { next(e); } +}); + +// ── POST /admin/smtp/test ─────────────────────────────────────────────────── + +router.post('/test', async (req, res, next) => { + try { + const { to } = z.object({ to: z.string().email() }).parse(req.body); + const cfg = getSmtpConfig(); + if (!cfg.host) throw new HttpError(400, 'Aucun serveur SMTP configuré.'); + + await sendMail({ + to, + subject: `Test SMTP — ${cfg.appName}`, + html: buildEmailHtml({ + title: 'Email de test', + body: `

Si vous recevez cet email, votre configuration SMTP fonctionne correctement.

`, + }), + }); + res.json({ ok: true, msg: `Email de test envoyé à ${to}.` }); + } catch (e) { next(e); } +}); + +// ── GET /admin/smtp/env ───────────────────────────────────────────────────── + +router.get('/env', (_req, res) => { + res.json({ + host: process.env.SMTP_HOST || '', + port: parseInt(process.env.SMTP_PORT || '587', 10), + email: process.env.SMTP_EMAIL || '', + username: process.env.SMTP_USERNAME || '', + appName: process.env.APP_NAME || '', + appUrl: process.env.APP_URL || '', + }); +}); + +export default router; diff --git a/backend/src/server.js b/backend/src/server.js index 7ec00a3..b6ddeb3 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -35,6 +35,8 @@ import { startAutoStatutJob } from './jobs/autoStatut.js'; import adminRouter from './routes/admin.js'; import invitationsRouter from './routes/invitations.js'; import auditLogsRouter from './routes/auditLogs.js'; +import smtpRouter from './routes/smtp.js'; +import generalRouter from './routes/general.js'; import tauxCreditImpotRouter from './routes/tauxCreditImpot.js'; import referentielRouter from './routes/referentiel.js'; import referentielPublicRouter from './routes/referentielPublic.js'; @@ -78,12 +80,15 @@ app.get('/api/app-info', (_, res) => { try { const cfg = getSmtpConfig(); const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get(); + const row = db.prepare('SELECT allow_registration, min_password_length FROM smtp_config WHERE id = 1').get(); res.json({ - appName: cfg.appName || 'Crowdlending Tracker', - iconUrl: icon ? `/api/icons-files/${icon.filename}` : null, + appName: cfg.appName || 'Crowdlending Tracker', + iconUrl: icon ? `/api/icons-files/${icon.filename}` : null, + allowRegistration: row ? row.allow_registration !== 0 : true, + minPasswordLength: row ? (row.min_password_length || 8) : 8, }); } catch { - res.json({ appName: 'Crowdlending Tracker', iconUrl: null }); + res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8 }); } }); @@ -111,6 +116,8 @@ app.use('/api/preferences', requireAuth, preferencesRouter); app.use('/api/icons', requireAuth, iconsRouter); app.use('/api/admin', requireAuth, requireAdmin, adminRouter); app.use('/api/admin/audit-logs', requireAuth, requireAdmin, auditLogsRouter); +app.use('/api/admin/smtp', requireAuth, requireAdmin, smtpRouter); +app.use('/api/admin/general', requireAuth, requireAdmin, generalRouter); // Invitations : routes admin protégées + routes publiques (register/validate) app.use('/api/admin/invitations', requireAuth, requireAdmin, invitationsRouter); app.use('/api/invitations', invitationsRouter); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 392b6c2..782235b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,5 @@ import { Routes, Route, Navigate } from 'react-router-dom'; +import { useState, useEffect } from 'react'; import { useAuth } from './context/AuthContext.jsx'; import Login from './pages/Login.jsx'; import Register from './pages/Register.jsx'; @@ -38,10 +39,18 @@ function AdminOnly({ children }) { } export default function App() { + const [allowRegistration, setAllowRegistration] = useState(true); + + useEffect(() => { + fetch('/api/app-info').then(r => r.json()).then(d => { + if (d.allowRegistration === false) setAllowRegistration(false); + }).catch(() => {}); + }, []); + return ( } /> - } /> + : } /> } /> } /> } /> diff --git a/frontend/src/components/PasswordStrength.jsx b/frontend/src/components/PasswordStrength.jsx index 40156ba..cdb19f3 100644 --- a/frontend/src/components/PasswordStrength.jsx +++ b/frontend/src/components/PasswordStrength.jsx @@ -7,13 +7,15 @@ * Affiche uniquement quand password est non vide. */ -const RULES = [ - { id: 'len', label: '8 caractères minimum', test: p => p.length >= 8 }, +function buildRules(minLen) { + return [ + { id: 'len', label: `${minLen} caractères minimum`, test: p => p.length >= minLen }, { id: 'upper', label: 'Une lettre majuscule (A–Z)', test: p => /[A-Z]/.test(p) }, { id: 'lower', label: 'Une lettre minuscule (a–z)', test: p => /[a-z]/.test(p) }, { id: 'digit', label: 'Un chiffre (0–9)', test: p => /[0-9]/.test(p) }, { id: 'special', label: 'Un caractère spécial (!@#…)', test: p => /[^A-Za-z0-9]/.test(p) }, -]; + ]; +} const LEVELS = [ { min: 0, label: 'Très faible', color: '#ef4444' }, @@ -32,9 +34,10 @@ function getLevel(score) { return LEVELS[0]; } -export default function PasswordStrength({ password }) { +export default function PasswordStrength({ password, minLength = 8 }) { if (!password) return null; + const RULES = buildRules(minLength); const results = RULES.map(r => ({ ...r, ok: r.test(password) })); const score = results.filter(r => r.ok).length; const level = getLevel(score); diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index fe6b199..26253d0 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -5,6 +5,7 @@ import AuditLogsSection from './admin/AuditLogsSection.jsx'; import JobLogsSection from './admin/JobLogsSection.jsx'; import IconsSection from './admin/IconsSection.jsx'; import SmtpSection from './admin/SmtpSection.jsx'; +import GeneralSection from './admin/GeneralSection.jsx'; /* ── Icônes nav ───────────────────────────────────────────────── */ function IconUsers() { return ; } @@ -13,12 +14,14 @@ function IconShield() { return ; } function IconTax() { return ; } function IconDatabase() { return ; } +function IconSettings() { return ; } function IconMail() { return ; } const NAV = [ { group: 'Administration de la plateforme', items: [ + { id: 'general', label: 'Général', icon: }, { id: 'users', label: 'Utilisateurs', icon: }, { id: 'audit-logs', label: 'Audit', icon: }, { id: 'job-logs', label: 'Logs des jobs', icon: }, @@ -70,6 +73,7 @@ export default function Admin() { ))}
+ {section === 'general' && } {section === 'users' && } {section === 'audit-logs' && } {section === 'job-logs' && } diff --git a/frontend/src/pages/InvitationRegister.jsx b/frontend/src/pages/InvitationRegister.jsx index 9877b9f..6a4ef42 100644 --- a/frontend/src/pages/InvitationRegister.jsx +++ b/frontend/src/pages/InvitationRegister.jsx @@ -29,7 +29,7 @@ function Wrap({ appInfo, children }) { export default function InvitationRegister() { const { token } = useParams(); const navigate = useNavigate(); - const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null }); + const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null, minPasswordLength: 8 }); const [inv, setInv] = useState(null); // { email, role, expiresAt } const [status, setStatus] = useState('loading'); // loading | valid | invalid | done const [errMsg, setErrMsg] = useState(''); @@ -173,13 +173,13 @@ export default function InvitationRegister() {
- set('password', e.target.value)} style={{ width: '100%' }} /> - + set('password', e.target.value)} style={{ width: '100%' }} /> +
- set('confirm', e.target.value)} style={{ width: '100%' }} /> + set('confirm', e.target.value)} style={{ width: '100%' }} />
diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index d88c832..46dfee7 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -9,7 +9,7 @@ export default function Register() { const [form, setForm] = useState({ email: '', password: '', displayName: '' }); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); - const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null }); + const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null, minPasswordLength: 8 }); useEffect(() => { fetch('/api/app-info') @@ -211,14 +211,14 @@ export default function Register() { - +
+ + {saved === 'ok' && ( + + + + + Paramètres enregistrés + + )} + {saved === 'err' && ( + Erreur lors de la sauvegarde. + )} + + + ); +} diff --git a/frontend/src/pages/admin/SmtpSection.jsx b/frontend/src/pages/admin/SmtpSection.jsx index cafbeab..e528ce4 100644 --- a/frontend/src/pages/admin/SmtpSection.jsx +++ b/frontend/src/pages/admin/SmtpSection.jsx @@ -77,8 +77,6 @@ const DEFAULT_STATE = { username: '', password: '', allowUnauth: false, - appName: 'Crowdlending Tracker', - appUrl: '', }; export default function SmtpSection() { @@ -109,8 +107,6 @@ export default function SmtpSection() { username: data.username || '', password: '', allowUnauth: !!data.allowUnauth, - appName: data.appName || 'Crowdlending Tracker', - appUrl: data.appUrl || '', }); setHasPassword(!!data.hasPassword); hasPasswordRef.current = !!data.hasPassword; @@ -239,33 +235,6 @@ export default function SmtpSection() {
- {/* Nom de la plateforme */} - - set('appName', e.target.value)} - placeholder="Crowdlending" - style={{ width: 280 }} - /> - - - {/* URL de la plateforme */} - - set('appUrl', e.target.value)} - placeholder="https://monapp.example.com" - style={{ width: 280 }} - /> - - - {/* Séparateur */} -
- {/* Activer */} { + fetch('/api/app-info').then(r => r.json()).then(d => { + if (d.minPasswordLength) setMinPasswordLength(d.minPasswordLength); + }).catch(() => {}); + }, []); + const [form, setForm] = useState({ email: '', password: '', displayName: '', role: 'user' }); const [loading, setLoading] = useState(false); const [err, setErr] = useState(null); @@ -356,8 +363,8 @@ function CreateUserModal({ open, onClose, onCreated }) {
- set('password', e.target.value)} placeholder="8 caractères minimum" /> - + set('password', e.target.value)} placeholder={`${minPasswordLength} caractères minimum`} /> +