Implementation du mail
This commit is contained in:
Generated
+19
-25
@@ -18,11 +18,11 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^8.0.11",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
}
|
||||||
"devDependencies": {}
|
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
@@ -646,21 +646,6 @@
|
|||||||
"npm": "1.2.8000 || >= 1.4.16"
|
"npm": "1.2.8000 || >= 1.4.16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser/node_modules/qs": {
|
|
||||||
"version": "6.15.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
|
|
||||||
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"side-channel": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/buffer": {
|
"node_modules/buffer": {
|
||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||||
@@ -1035,14 +1020,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.1",
|
"version": "4.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
"body-parser": "~1.20.3",
|
"body-parser": "~1.20.5",
|
||||||
"content-disposition": "~0.5.4",
|
"content-disposition": "~0.5.4",
|
||||||
"content-type": "~1.0.4",
|
"content-type": "~1.0.4",
|
||||||
"cookie": "~0.7.1",
|
"cookie": "~0.7.1",
|
||||||
@@ -1061,7 +1046,7 @@
|
|||||||
"parseurl": "~1.3.3",
|
"parseurl": "~1.3.3",
|
||||||
"path-to-regexp": "~0.1.12",
|
"path-to-regexp": "~0.1.12",
|
||||||
"proxy-addr": "~2.0.7",
|
"proxy-addr": "~2.0.7",
|
||||||
"qs": "~6.14.0",
|
"qs": "~6.15.1",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "~1.2.1",
|
||||||
"safe-buffer": "5.2.1",
|
"safe-buffer": "5.2.1",
|
||||||
"send": "~0.19.0",
|
"send": "~0.19.0",
|
||||||
@@ -1607,6 +1592,15 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
|
||||||
|
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -1730,9 +1724,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.2",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^8.0.11",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* seed-app-logo.js — Enregistre app-logo.svg dans la bibliothèque d'icônes.
|
||||||
|
* Usage (depuis le dossier backend/) : node scripts/seed-app-logo.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import db from '../src/db/index.js';
|
||||||
|
|
||||||
|
const existing = db.prepare("SELECT id FROM app_icons WHERE name = 'logo-app'").get();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO app_icons_history (icon_id, filename, replaced_at)
|
||||||
|
SELECT id, filename, datetime('now') FROM app_icons WHERE name = 'logo-app'
|
||||||
|
`).run();
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE app_icons
|
||||||
|
SET filename = 'app-logo.svg', description = 'Logo principal de la plateforme', updated_at = datetime('now')
|
||||||
|
WHERE name = 'logo-app'
|
||||||
|
`).run();
|
||||||
|
console.log('✓ Icône logo-app mise à jour → app-logo.svg');
|
||||||
|
} else {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO app_icons (name, filename, description)
|
||||||
|
VALUES ('logo-app', 'app-logo.svg', 'Logo principal de la plateforme')
|
||||||
|
`).run();
|
||||||
|
console.log('✓ Icône logo-app créée → app-logo.svg');
|
||||||
|
}
|
||||||
@@ -999,11 +999,18 @@ db.exec(`
|
|||||||
{ name: 'remboursement', filename: 'icon_remboursement_seed.svg', description: 'Remboursement' },
|
{ name: 'remboursement', filename: 'icon_remboursement_seed.svg', description: 'Remboursement' },
|
||||||
{ name: 'retrait', filename: 'icon_retrait_seed.svg', description: 'Retrait de fonds' },
|
{ name: 'retrait', filename: 'icon_retrait_seed.svg', description: 'Retrait de fonds' },
|
||||||
{ name: 'tax', filename: 'icon_tax_1780230337883.svg', description: 'Fiscalité / Tax' },
|
{ name: 'tax', filename: 'icon_tax_1780230337883.svg', description: 'Fiscalité / Tax' },
|
||||||
|
{ name: 'logo-app', filename: 'app-logo.svg', description: 'Logo principal de la plateforme' },
|
||||||
];
|
];
|
||||||
const ins = db.prepare(
|
const ins = db.prepare(
|
||||||
'INSERT OR IGNORE INTO app_icons (name, filename, description) VALUES (?,?,?)'
|
'INSERT OR IGNORE INTO app_icons (name, filename, description) VALUES (?,?,?)'
|
||||||
);
|
);
|
||||||
for (const s of seeds) ins.run(s.name, s.filename, s.description);
|
for (const s of seeds) ins.run(s.name, s.filename, s.description);
|
||||||
|
} else {
|
||||||
|
// Garantit que logo-app existe même sur une DB déjà peuplée
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO app_icons (name, filename, description)
|
||||||
|
VALUES ('logo-app', 'app-logo.svg', 'Logo principal de la plateforme')
|
||||||
|
`).run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1683,3 +1690,46 @@ db.exec(`
|
|||||||
console.log(`[DB] ${toFix.length} date_cible aberrantes corrigées.`);
|
console.log(`[DB] ${toFix.length} date_cible aberrantes corrigées.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Migration : table smtp_config ─────────────────────────────────────────
|
||||||
|
{
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS smtp_config (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
host TEXT,
|
||||||
|
port INTEGER NOT NULL DEFAULT 587,
|
||||||
|
secure INTEGER NOT NULL DEFAULT 0,
|
||||||
|
email TEXT,
|
||||||
|
username TEXT,
|
||||||
|
password TEXT,
|
||||||
|
allow_unauth INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Seed row unique (id=1) si elle n'existe pas encore
|
||||||
|
const existing = db.prepare('SELECT id FROM smtp_config WHERE id = 1').get();
|
||||||
|
if (!existing) {
|
||||||
|
// Pré-remplir depuis les variables d'environnement si disponibles
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO smtp_config (id, enabled, host, port, email, username, password)
|
||||||
|
VALUES (1, 0, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
process.env.SMTP_HOST || null,
|
||||||
|
parseInt(process.env.SMTP_PORT || '587', 10),
|
||||||
|
process.env.SMTP_EMAIL || null,
|
||||||
|
process.env.SMTP_USERNAME || null,
|
||||||
|
process.env.SMTP_PASSWORD || null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajout des colonnes app_name et app_url si absentes
|
||||||
|
const smtpCols = db.prepare('PRAGMA table_info(smtp_config)').all().map(c => c.name);
|
||||||
|
if (!smtpCols.includes('app_name'))
|
||||||
|
db.exec(`ALTER TABLE smtp_config ADD COLUMN app_name TEXT DEFAULT 'Crowdlending'`);
|
||||||
|
if (!smtpCols.includes('app_url'))
|
||||||
|
db.exec(`ALTER TABLE smtp_config ADD COLUMN app_url TEXT DEFAULT ''`);
|
||||||
|
|
||||||
|
console.log('[DB] Table smtp_config OK');
|
||||||
|
}
|
||||||
|
|||||||
@@ -371,3 +371,148 @@ router.delete('/inv-suggestions/secteurs/:id', (req, res, next) => {
|
|||||||
res.json({ ok: true, msg: `"${row.nom}" supprimé.` });
|
res.json({ ok: true, msg: `"${row.nom}" supprimé.` });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── SMTP ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** Retourne la config SMTP (mot de passe masqué) */
|
||||||
|
router.get('/smtp', (req, res) => {
|
||||||
|
const row = db.prepare('SELECT * FROM smtp_config WHERE id = 1').get();
|
||||||
|
if (!row) return res.json({});
|
||||||
|
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',
|
||||||
|
appUrl: row.app_url || '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Retourne les valeurs .env pour pré-remplissage */
|
||||||
|
router.get('/smtp/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 || '',
|
||||||
|
hasPassword: !!(process.env.SMTP_PASSWORD),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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().optional(),
|
||||||
|
appUrl: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Sauvegarde la config SMTP */
|
||||||
|
router.put('/smtp', (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const body = SmtpSchema.parse(req.body);
|
||||||
|
|
||||||
|
const existing = db.prepare('SELECT password FROM smtp_config WHERE id = 1').get();
|
||||||
|
const passwordToStore = body.password !== undefined
|
||||||
|
? (body.password || null)
|
||||||
|
: (existing?.password || null);
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO smtp_config (id, enabled, host, port, secure, email, username, password, allow_unauth, app_name, app_url, updated_at)
|
||||||
|
VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
enabled = excluded.enabled,
|
||||||
|
host = excluded.host,
|
||||||
|
port = excluded.port,
|
||||||
|
secure = excluded.secure,
|
||||||
|
email = excluded.email,
|
||||||
|
username = excluded.username,
|
||||||
|
password = excluded.password,
|
||||||
|
allow_unauth = excluded.allow_unauth,
|
||||||
|
app_name = excluded.app_name,
|
||||||
|
app_url = excluded.app_url,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`).run(
|
||||||
|
body.enabled ? 1 : 0,
|
||||||
|
body.host ?? null,
|
||||||
|
body.port ?? 587,
|
||||||
|
body.secure ? 1 : 0,
|
||||||
|
body.email ?? null,
|
||||||
|
body.username ?? null,
|
||||||
|
passwordToStore,
|
||||||
|
body.allowUnauth ? 1 : 0,
|
||||||
|
body.appName ?? 'Crowdlending',
|
||||||
|
body.appUrl ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ ok: true, msg: 'Configuration SMTP sauvegardée.' });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Traduit les erreurs nodemailer en messages lisibles */
|
||||||
|
function smtpErrorMessage(e) {
|
||||||
|
const msg = e.message || '';
|
||||||
|
const code = e.code || '';
|
||||||
|
// Connexion
|
||||||
|
if (code === 'ETIMEDOUT' || msg.includes('Greeting never received'))
|
||||||
|
return 'Impossible de joindre le serveur SMTP (timeout). Vérifiez l\'hôte, le port et que le port sortant n\'est pas bloqué par un pare-feu.';
|
||||||
|
if (code === 'ECONNREFUSED')
|
||||||
|
return 'Connexion refusée par le serveur SMTP. Vérifiez l\'hôte et le port.';
|
||||||
|
if (code === 'ENOTFOUND')
|
||||||
|
return `Hôte SMTP introuvable ("${e.hostname || ''}"). Vérifiez le nom du serveur.`;
|
||||||
|
// TLS / certificat
|
||||||
|
if (code === 'ESOCKET' || msg.includes('self-signed') || msg.includes('certificate'))
|
||||||
|
return 'Erreur de certificat TLS. Activez "Faire confiance aux certificats non autorisés" si votre serveur utilise un certificat auto-signé, ou désactivez TLS si vous utilisez le port 587 (STARTTLS).';
|
||||||
|
if (msg.includes('wrong version number') || msg.includes('SSL routines'))
|
||||||
|
return 'Protocole TLS incompatible. Si vous utilisez le port 587, désactivez "Connexion sécurisée (TLS)". Si vous utilisez le port 465, activez-la.';
|
||||||
|
// Auth
|
||||||
|
if (msg.includes('Invalid login') || msg.includes('535') || msg.includes('Authentication'))
|
||||||
|
return 'Authentification refusée. Vérifiez le nom d\'utilisateur et le mot de passe (certains services exigent une clé API, pas le mot de passe du compte).';
|
||||||
|
if (msg.includes('534') || msg.includes('Username and Password not accepted'))
|
||||||
|
return 'Identifiants rejetés. Vérifiez que vous utilisez bien une clé API ou un mot de passe d\'application, pas le mot de passe principal.';
|
||||||
|
// SMTP désactivé / config manquante
|
||||||
|
if (msg.includes('SMTP désactivé') || msg.includes('incomplète'))
|
||||||
|
return msg;
|
||||||
|
// Fallback
|
||||||
|
return `Erreur SMTP : ${msg}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Envoie un email de test */
|
||||||
|
router.post('/smtp/test', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { to } = z.object({ to: z.string().email() }).parse(req.body);
|
||||||
|
|
||||||
|
const { sendMail, buildEmailHtml, getSmtpConfig } = await import('../utils/mailer.js');
|
||||||
|
const cfg = getSmtpConfig();
|
||||||
|
const date = new Date().toLocaleString('fr-FR', { dateStyle: 'long', timeStyle: 'short' });
|
||||||
|
|
||||||
|
await sendMail({
|
||||||
|
to,
|
||||||
|
subject: `Test SMTP — ${cfg.appName}`,
|
||||||
|
text: `Configuration SMTP opérationnelle.\n\nCet email de test a été envoyé depuis ${cfg.appName} le ${date}.\n\n${cfg.appUrl || ''}`,
|
||||||
|
html: buildEmailHtml({
|
||||||
|
title: 'Configuration email opérationnelle ✓',
|
||||||
|
body: `
|
||||||
|
<p>Votre configuration SMTP fonctionne correctement.</p>
|
||||||
|
<p style="color:#6b7280;font-size:14px;">Email de test envoyé le <strong>${date}</strong>.</p>
|
||||||
|
`,
|
||||||
|
ctaLabel: `Accéder à ${cfg.appName}`,
|
||||||
|
ctaUrl: cfg.appUrl || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true, msg: `Email de test envoyé à ${to}.` });
|
||||||
|
} catch (e) {
|
||||||
|
// On retourne un 200 avec ok:false pour que le frontend affiche le message sans crash
|
||||||
|
res.json({ ok: false, msg: smtpErrorMessage(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* mailer.js — Module d'envoi d'email
|
||||||
|
*
|
||||||
|
* Charge la config SMTP depuis la DB (source de vérité).
|
||||||
|
* Fallback : variables d'environnement SMTP_* si la DB est vide.
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* import { sendMail, buildEmailHtml } from '../utils/mailer.js';
|
||||||
|
* await sendMail({ to, subject, html: buildEmailHtml({ title, body, ctaLabel, ctaUrl }) });
|
||||||
|
*/
|
||||||
|
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import db from '../db/index.js';
|
||||||
|
|
||||||
|
/** Charge la config SMTP active (DB en priorité, .env en fallback). */
|
||||||
|
export function getSmtpConfig() {
|
||||||
|
const row = db.prepare('SELECT * FROM smtp_config WHERE id = 1').get();
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: !!(row?.enabled),
|
||||||
|
host: row?.host || process.env.SMTP_HOST || '',
|
||||||
|
port: row?.port || parseInt(process.env.SMTP_PORT || '587', 10),
|
||||||
|
secure: !!(row?.secure),
|
||||||
|
email: row?.email || process.env.SMTP_EMAIL || '',
|
||||||
|
username: row?.username || process.env.SMTP_USERNAME || '',
|
||||||
|
password: row?.password || process.env.SMTP_PASSWORD || '',
|
||||||
|
allowUnauth: !!(row?.allow_unauth),
|
||||||
|
appName: row?.app_name || process.env.APP_NAME || 'Crowdlending',
|
||||||
|
appUrl: row?.app_url || process.env.APP_URL || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Résout l'URL absolue de l'icône principale de l'app (peut être null). */
|
||||||
|
function getAppIconUrl(appUrl) {
|
||||||
|
if (!appUrl) return null;
|
||||||
|
try {
|
||||||
|
const icon = db.prepare(
|
||||||
|
`SELECT filename FROM app_icons ORDER BY updated_at DESC LIMIT 1`
|
||||||
|
).get();
|
||||||
|
return icon ? `${appUrl.replace(/\/$/, '')}/api/icons-files/${icon.filename}` : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit un email HTML au format plateforme.
|
||||||
|
*
|
||||||
|
* @param {{
|
||||||
|
* title: string, // Titre principal (h1)
|
||||||
|
* body: string, // Corps HTML (paragraphes, etc.)
|
||||||
|
* ctaLabel?: string, // Texte du bouton CTA
|
||||||
|
* ctaUrl?: string, // URL du bouton CTA
|
||||||
|
* appName?: string, // Surcharge nom app
|
||||||
|
* appUrl?: string, // Surcharge URL app
|
||||||
|
* }} opts
|
||||||
|
*/
|
||||||
|
export function buildEmailHtml({ title, body, ctaLabel, ctaUrl, appName, appUrl } = {}) {
|
||||||
|
const cfg = getSmtpConfig();
|
||||||
|
const name = appName || cfg.appName;
|
||||||
|
const url = appUrl || cfg.appUrl;
|
||||||
|
const iconUrl = getAppIconUrl(url);
|
||||||
|
|
||||||
|
const iconHtml = iconUrl
|
||||||
|
? `<img src="${iconUrl}" alt="${name}" width="48" height="48"
|
||||||
|
style="display:block;border-radius:10px;margin:0 auto 12px;" />`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const ctaHtml = ctaLabel && ctaUrl
|
||||||
|
? `<div style="text-align:center;margin:32px 0 8px;">
|
||||||
|
<a href="${ctaUrl}"
|
||||||
|
style="display:inline-block;padding:12px 28px;background:#6366f1;color:#fff;
|
||||||
|
text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">
|
||||||
|
${ctaLabel}
|
||||||
|
</a>
|
||||||
|
</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const footerLink = url
|
||||||
|
? `<a href="${url}" style="color:#6366f1;text-decoration:none;">${url}</a>`
|
||||||
|
: name;
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||||
|
<title>${title}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background:#f4f4f5;font-family:system-ui,-apple-system,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table width="560" cellpadding="0" cellspacing="0"
|
||||||
|
style="background:#ffffff;border-radius:12px;overflow:hidden;
|
||||||
|
box-shadow:0 2px 8px rgba(0,0,0,0.06);max-width:100%;">
|
||||||
|
|
||||||
|
<!-- En-tête -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#6366f1;padding:32px 40px;text-align:center;">
|
||||||
|
${iconHtml}
|
||||||
|
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:700;
|
||||||
|
letter-spacing:-0.3px;">${name}</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Corps -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:36px 40px 8px;">
|
||||||
|
<h2 style="margin:0 0 16px;color:#111827;font-size:18px;font-weight:600;">
|
||||||
|
${title}
|
||||||
|
</h2>
|
||||||
|
<div style="color:#374151;font-size:15px;line-height:1.65;">
|
||||||
|
${body}
|
||||||
|
</div>
|
||||||
|
${ctaHtml}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Pied de page -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 40px 32px;border-top:1px solid #f3f4f6;
|
||||||
|
text-align:center;color:#9ca3af;font-size:12px;margin-top:16px;">
|
||||||
|
Cet email a été envoyé automatiquement par ${footerLink}.<br/>
|
||||||
|
Merci de ne pas y répondre directement.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Crée un transporteur nodemailer à partir de la config active. */
|
||||||
|
function createTransport(cfg) {
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: cfg.host,
|
||||||
|
port: cfg.port,
|
||||||
|
secure: cfg.secure,
|
||||||
|
auth: {
|
||||||
|
user: cfg.username,
|
||||||
|
pass: cfg.password,
|
||||||
|
},
|
||||||
|
tls: cfg.allowUnauth ? { rejectUnauthorized: false } : undefined,
|
||||||
|
connectionTimeout: 8000,
|
||||||
|
greetingTimeout: 5000,
|
||||||
|
socketTimeout: 10000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie un email.
|
||||||
|
* @param {{ to: string, subject: string, text?: string, html?: string }} opts
|
||||||
|
*/
|
||||||
|
export async function sendMail({ to, subject, text, html }) {
|
||||||
|
const cfg = getSmtpConfig();
|
||||||
|
|
||||||
|
if (!cfg.enabled) {
|
||||||
|
throw new Error('SMTP désactivé — activez-le dans Administration > SMTP.');
|
||||||
|
}
|
||||||
|
if (!cfg.host || !cfg.email) {
|
||||||
|
throw new Error('Config SMTP incomplète (hôte ou courriel manquant).');
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = createTransport(cfg);
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: `"${cfg.appName}" <${cfg.email}>`,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie la connexion SMTP sans envoyer d'email.
|
||||||
|
* Renvoie { ok: true } ou lance une erreur.
|
||||||
|
*/
|
||||||
|
export async function verifySmtp() {
|
||||||
|
const cfg = getSmtpConfig();
|
||||||
|
if (!cfg.host) throw new Error('Hôte SMTP manquant.');
|
||||||
|
const transporter = createTransport(cfg);
|
||||||
|
await transporter.verify();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import UsersSection from './admin/UsersSection.jsx';
|
|||||||
import CreateUserSection from './admin/CreateUserSection.jsx';
|
import CreateUserSection from './admin/CreateUserSection.jsx';
|
||||||
import JobLogsSection from './admin/JobLogsSection.jsx';
|
import JobLogsSection from './admin/JobLogsSection.jsx';
|
||||||
import IconsSection from './admin/IconsSection.jsx';
|
import IconsSection from './admin/IconsSection.jsx';
|
||||||
|
import SmtpSection from './admin/SmtpSection.jsx';
|
||||||
|
|
||||||
/* ── Icônes nav ───────────────────────────────────────────────── */
|
/* ── Icônes nav ───────────────────────────────────────────────── */
|
||||||
function IconUsers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
|
function IconUsers() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
|
||||||
@@ -13,6 +14,7 @@ function IconActivity() { return <svg width="15" height="15" viewBox="0 0 24 24"
|
|||||||
function IconImage() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>; }
|
function IconImage() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>; }
|
||||||
function IconTax() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>; }
|
function IconTax() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>; }
|
||||||
function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>; }
|
function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>; }
|
||||||
|
function IconMail() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>; }
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{
|
{
|
||||||
@@ -31,6 +33,12 @@ const NAV = [
|
|||||||
{ id: 'fiscalite', label: 'Fiscalité', icon: <IconTax />, href: '/admin/fiscalite' },
|
{ id: 'fiscalite', label: 'Fiscalité', icon: <IconTax />, href: '/admin/fiscalite' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
group: 'Notifications',
|
||||||
|
items: [
|
||||||
|
{ id: 'smtp', label: 'SMTP', icon: <IconMail /> },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
@@ -68,6 +76,7 @@ export default function Admin() {
|
|||||||
{section === 'create' && <CreateUserSection onCreated={() => setRefreshKey(k => k + 1)} />}
|
{section === 'create' && <CreateUserSection onCreated={() => setRefreshKey(k => k + 1)} />}
|
||||||
{section === 'job-logs' && <JobLogsSection />}
|
{section === 'job-logs' && <JobLogsSection />}
|
||||||
{section === 'icons' && <IconsSection />}
|
{section === 'icons' && <IconsSection />}
|
||||||
|
{section === 'smtp' && <SmtpSection />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { api } from '../../api.js';
|
||||||
|
import { useAuth } from '../../context/AuthContext.jsx';
|
||||||
|
|
||||||
|
/* ── Icône œil (show/hide password) ───────────────────────────────────── */
|
||||||
|
function IconEye({ open }) {
|
||||||
|
return open
|
||||||
|
? <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||||
|
: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toggle switch ─────────────────────────────────────────────────────── */
|
||||||
|
function Toggle({ checked, onChange, id }) {
|
||||||
|
return (
|
||||||
|
<label htmlFor={id} style={{ cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={e => onChange(e.target.checked)}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 44,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: checked ? 'var(--primary)' : 'var(--border)',
|
||||||
|
position: 'relative',
|
||||||
|
transition: 'background 0.2s',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 3,
|
||||||
|
left: checked ? 23 : 3,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: '#fff',
|
||||||
|
transition: 'left 0.2s',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||||
|
}} />
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Ligne de paramètre ────────────────────────────────────────────────── */
|
||||||
|
function SettingRow({ label, description, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '12px 24px',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '18px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, color: 'var(--text)', marginBottom: description ? 4 : 0 }}>{label}</div>
|
||||||
|
{description && <div style={{ fontSize: '0.85rem', color: 'var(--text-muted)', lineHeight: 1.4 }}>{description}</div>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_STATE = {
|
||||||
|
enabled: false,
|
||||||
|
host: '',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
email: '',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
allowUnauth: false,
|
||||||
|
appName: 'Crowdlending',
|
||||||
|
appUrl: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SmtpSection() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [form, setForm] = useState(DEFAULT_STATE);
|
||||||
|
const [hasPassword, setHasPassword] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const passwordTyped = useRef(false); // true uniquement si l'utilisateur a tapé manuellement
|
||||||
|
const [testEmail, setTestEmail] = useState(user?.email || '');
|
||||||
|
const [result, setResult] = useState(null); // { ok, msg }
|
||||||
|
const [testResult, setTestResult] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [loadingEnv, setLoadingEnv] = useState(false);
|
||||||
|
const [saveStatus, setSaveStatus] = useState(null); // 'saving' | 'saved' | 'error'
|
||||||
|
const debounceRef = useRef(null);
|
||||||
|
const isFirstLoad = useRef(true);
|
||||||
|
const hasPasswordRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/admin/smtp').then(data => {
|
||||||
|
setForm({
|
||||||
|
enabled: !!data.enabled,
|
||||||
|
host: data.host || '',
|
||||||
|
port: data.port || 587,
|
||||||
|
secure: !!data.secure,
|
||||||
|
email: data.email || '',
|
||||||
|
username: data.username || '',
|
||||||
|
password: '',
|
||||||
|
allowUnauth: !!data.allowUnauth,
|
||||||
|
appName: data.appName || 'Crowdlending',
|
||||||
|
appUrl: data.appUrl || '',
|
||||||
|
});
|
||||||
|
setHasPassword(!!data.hasPassword);
|
||||||
|
hasPasswordRef.current = !!data.hasPassword;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const doSave = useCallback(async (currentForm) => {
|
||||||
|
setSaveStatus('saving');
|
||||||
|
try {
|
||||||
|
const payload = { ...currentForm };
|
||||||
|
// N'envoyer le mot de passe que si l'utilisateur l'a tapé manuellement
|
||||||
|
if (!payload.password || !passwordTyped.current) delete payload.password;
|
||||||
|
await api.put('/admin/smtp', payload);
|
||||||
|
if (payload.password) {
|
||||||
|
setHasPassword(true);
|
||||||
|
hasPasswordRef.current = true;
|
||||||
|
}
|
||||||
|
setSaveStatus('saved');
|
||||||
|
setTimeout(() => setSaveStatus(null), 2500);
|
||||||
|
} catch (e) {
|
||||||
|
setSaveStatus('error');
|
||||||
|
setResult({ ok: false, msg: e.message || 'Erreur lors de la sauvegarde.' });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const set = (key, value) => {
|
||||||
|
setForm(f => {
|
||||||
|
const next = { ...f, [key]: value };
|
||||||
|
// Ne pas déclencher l'auto-save au chargement initial
|
||||||
|
if (isFirstLoad.current) { isFirstLoad.current = false; return next; }
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => doSave(next), 1500);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nettoyage du timer au démontage
|
||||||
|
useEffect(() => () => clearTimeout(debounceRef.current), []);
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
if (!testEmail) return;
|
||||||
|
setTesting(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const data = await api.post('/admin/smtp/test', { to: testEmail });
|
||||||
|
setTestResult({ ok: data.ok, msg: data.msg });
|
||||||
|
} catch (e) {
|
||||||
|
setTestResult({ ok: false, msg: e.message || 'Erreur lors du test.' });
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadEnv = async () => {
|
||||||
|
setLoadingEnv(true);
|
||||||
|
try {
|
||||||
|
const data = await api.get('/admin/smtp/env');
|
||||||
|
setForm(f => {
|
||||||
|
const next = {
|
||||||
|
...f,
|
||||||
|
host: data.host || f.host,
|
||||||
|
port: data.port || f.port,
|
||||||
|
email: data.email || f.email,
|
||||||
|
username: data.username || f.username,
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => doSave(next), 1500);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (data.hasPassword) { setHasPassword(true); hasPasswordRef.current = true; }
|
||||||
|
} catch (e) {
|
||||||
|
setResult({ ok: false, msg: 'Impossible de charger les valeurs .env.' });
|
||||||
|
} finally {
|
||||||
|
setLoadingEnv(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="account-section">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||||
|
<h2 style={{ margin: 0 }}>SMTP</h2>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
{saveStatus === 'saving' && (
|
||||||
|
<span style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>Sauvegarde…</span>
|
||||||
|
)}
|
||||||
|
{saveStatus === 'saved' && (
|
||||||
|
<span style={{ fontSize: '0.82rem', color: 'var(--success, #15803d)' }}>✓ Sauvegardé</span>
|
||||||
|
)}
|
||||||
|
{saveStatus === 'error' && (
|
||||||
|
<span style={{ fontSize: '0.82rem', color: 'var(--danger, #dc2626)' }}>Erreur</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => handleLoadEnv()}
|
||||||
|
disabled={loadingEnv}
|
||||||
|
style={{ fontSize: '0.82rem' }}
|
||||||
|
>
|
||||||
|
{loadingEnv ? 'Chargement…' : 'Charger depuis .env'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginBottom: 24 }}>
|
||||||
|
Configuration du serveur de messagerie sortant. Utilisée pour les alertes, rapports et réinitialisation de mot de passe.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<div
|
||||||
|
className={result.ok ? 'alert-success' : 'alert-error'}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 16,
|
||||||
|
background: result.ok ? 'var(--success-bg, #ecfdf5)' : 'var(--danger-bg, #fef2f2)',
|
||||||
|
color: result.ok ? 'var(--success, #15803d)' : 'var(--danger, #dc2626)',
|
||||||
|
border: `1px solid ${result.ok ? 'var(--success, #86efac)' : 'var(--danger, #fca5a5)'}`,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{result.msg}</span>
|
||||||
|
<button onClick={() => setResult(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', fontSize: 16 }}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card" style={{ padding: '0 24px' }}>
|
||||||
|
|
||||||
|
{/* Nom de la plateforme */}
|
||||||
|
<SettingRow label="Nom de la plateforme" description="Affiché dans l'en-tête et le pied de page des emails envoyés.">
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="text"
|
||||||
|
value={form.appName}
|
||||||
|
onChange={e => set('appName', e.target.value)}
|
||||||
|
placeholder="Crowdlending"
|
||||||
|
style={{ width: 280 }}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* URL de la plateforme */}
|
||||||
|
<SettingRow label="URL de la plateforme" description="Utilisée pour le bouton de redirection dans les emails. Inclure le protocole (https://).">
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="url"
|
||||||
|
value={form.appUrl}
|
||||||
|
onChange={e => set('appUrl', e.target.value)}
|
||||||
|
placeholder="https://monapp.example.com"
|
||||||
|
style={{ width: 280 }}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* Séparateur */}
|
||||||
|
<div style={{ borderBottom: '1px solid var(--border)', margin: '4px 0' }} />
|
||||||
|
|
||||||
|
{/* Activer */}
|
||||||
|
<SettingRow
|
||||||
|
label="Activer"
|
||||||
|
description="Active SMTP. Activez ceci uniquement si vous avez saisi l'hôte, le port, le courriel, l'utilisateur et son mot de passe."
|
||||||
|
>
|
||||||
|
<Toggle id="smtp-enabled" checked={form.enabled} onChange={v => set('enabled', v)} />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* Hôte */}
|
||||||
|
<SettingRow label="Hôte" description="Nom du serveur SMTP">
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="text"
|
||||||
|
value={form.host}
|
||||||
|
onChange={e => set('host', e.target.value)}
|
||||||
|
placeholder="smtp.example.com"
|
||||||
|
style={{ width: 280 }}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* Port */}
|
||||||
|
<SettingRow label="Port" description="Port du serveur SMTP">
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="number"
|
||||||
|
value={form.port}
|
||||||
|
onChange={e => set('port', parseInt(e.target.value, 10) || 587)}
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* Connexion sécurisée (TLS) */}
|
||||||
|
<SettingRow label="Connexion sécurisée (TLS)" description="Utiliser TLS dès la connexion (port 465). Désactivez pour STARTTLS (port 587).">
|
||||||
|
<Toggle id="smtp-secure" checked={form.secure} onChange={v => set('secure', v)} />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* Courriel */}
|
||||||
|
<SettingRow label="Courriel" description="Adresse email à partir de laquelle les courriels sont envoyés">
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => set('email', e.target.value)}
|
||||||
|
placeholder="no-reply@example.com"
|
||||||
|
style={{ width: 280 }}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* Nom d'utilisateur */}
|
||||||
|
<SettingRow label="Nom d'utilisateur" description="Nom d'utilisateur du serveur SMTP">
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="text"
|
||||||
|
value={form.username}
|
||||||
|
onChange={e => set('username', e.target.value)}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
autoComplete="off"
|
||||||
|
style={{ width: 280 }}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* Mot de passe */}
|
||||||
|
<SettingRow
|
||||||
|
label="Mot de passe"
|
||||||
|
description={hasPassword && !form.password ? 'Un mot de passe est déjà enregistré. Laissez vide pour le conserver.' : 'Mot de passe du serveur SMTP'}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'relative', width: 280 }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={form.password}
|
||||||
|
onChange={e => { passwordTyped.current = true; set('password', e.target.value); }}
|
||||||
|
placeholder={hasPassword ? '••••••••' : 'Mot de passe'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
style={{ width: '100%', paddingRight: 36 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(s => !s)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
color: 'var(--text-muted)', padding: 0, display: 'flex',
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<IconEye open={showPassword} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
{/* Certificats non autorisés */}
|
||||||
|
<SettingRow
|
||||||
|
label="Faire confiance aux certificats de serveurs SMTP non autorisés"
|
||||||
|
description="Ne permettez ceci que si vous avez besoin de faire confiance aux certificats autosignés."
|
||||||
|
>
|
||||||
|
<Toggle id="smtp-allowUnauth" checked={form.allowUnauth} onChange={v => set('allowUnauth', v)} />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Section email de test */}
|
||||||
|
<div className="card" style={{ marginTop: 24, padding: '20px 24px' }}>
|
||||||
|
<h3 style={{ margin: '0 0 12px' }}>Envoyer un email de test</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginBottom: 16, fontSize: '0.9rem' }}>
|
||||||
|
Vérifiez que votre configuration SMTP fonctionne en envoyant un email de test. Sauvegardez d'abord vos paramètres.
|
||||||
|
</p>
|
||||||
|
{testResult && (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: 14,
|
||||||
|
background: testResult.ok ? 'var(--success-bg, #ecfdf5)' : 'var(--danger-bg, #fef2f2)',
|
||||||
|
color: testResult.ok ? 'var(--success, #15803d)' : 'var(--danger, #dc2626)',
|
||||||
|
border: `1px solid ${testResult.ok ? 'var(--success, #86efac)' : 'var(--danger, #fca5a5)'}`,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<span>{testResult.msg}</span>
|
||||||
|
<button onClick={() => setTestResult(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'inherit', fontSize: 16 }}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
type="email"
|
||||||
|
value={testEmail}
|
||||||
|
onChange={e => setTestEmail(e.target.value)}
|
||||||
|
placeholder="destinataire@example.com"
|
||||||
|
style={{ flex: 1, maxWidth: 320 }}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleTest()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => handleTest()}
|
||||||
|
disabled={testing || !testEmail}
|
||||||
|
>
|
||||||
|
{testing ? 'Envoi…' : 'Envoyer un courriel de test'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user