This commit is contained in:
2026-06-14 22:35:27 +02:00
parent 6f3dc0c05a
commit a6fc9f445f
6 changed files with 55 additions and 154 deletions
+9
View File
@@ -1789,6 +1789,15 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_dev_uid ON two_fa_trusted_devices(
} }
} }
// ── Migration : last_seen_at sur two_fa_trusted_devices ──────────────────────
{
const devCols2 = db.prepare('PRAGMA table_info(two_fa_trusted_devices)').all().map(c => c.name);
if (!devCols2.includes('last_seen_at')) {
db.exec('ALTER TABLE two_fa_trusted_devices ADD COLUMN last_seen_at TEXT');
console.log('[DB] two_fa_trusted_devices.last_seen_at ajouté');
}
}
console.log('[DB] Migrations 2FA OK'); console.log('[DB] Migrations 2FA OK');
export default db; export default db;
+7
View File
@@ -18,6 +18,13 @@ export function requireAuth(req, res, next) {
try { try {
const decoded = jwt.verify(token, JWT_SECRET); const decoded = jwt.verify(token, JWT_SECRET);
req.user = { id: decoded.sub, email: decoded.email }; req.user = { id: decoded.sub, email: decoded.email };
// Mettre à jour last_seen_at si un device token valide est présent
const deviceToken = req.headers['x-device-token'];
if (deviceToken) {
db.prepare(
"UPDATE two_fa_trusted_devices SET last_seen_at = datetime('now') WHERE token = ? AND user_id = ? AND expires_at > datetime('now')"
).run(deviceToken, decoded.sub);
}
next(); next();
} catch { } catch {
return res.status(401).json({ error: 'Invalid or expired token' }); return res.status(401).json({ error: 'Invalid or expired token' });
+2 -146
View File
@@ -48,7 +48,7 @@ const router = Router();
/** Liste tous les utilisateurs */ /** Liste tous les utilisateurs */
router.get('/users', (req, res) => { router.get('/users', (req, res) => {
const users = db.prepare(` const users = db.prepare(`
SELECT id, email, display_name, role, email_verified, created_at SELECT id, email, display_name, role, email_verified, totp_enabled, created_at
FROM users FROM users
ORDER BY id ASC ORDER BY id ASC
`).all(); `).all();
@@ -291,7 +291,6 @@ router.post('/jobs/:name/run', (req, res, next) => {
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
export default router;
/* ── Catégories & secteurs suggérés par les utilisateurs ─────────────── */ /* ── Catégories & secteurs suggérés par les utilisateurs ─────────────── */
@@ -383,147 +382,4 @@ router.delete('/inv-suggestions/secteurs/:id', (req, res, next) => {
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
/* ── SMTP ─────────────────────────────────────────────────────────────── */ export default router;
/** 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 Tracker',
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 Tracker',
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) });
}
});
+9 -8
View File
@@ -155,7 +155,7 @@ router.post('/login', async (req, res, next) => {
router.get('/me', requireAuth, (req, res) => { router.get('/me', requireAuth, (req, res) => {
const user = db const user = db
.prepare('SELECT id, email, display_name, role, email_verified FROM users WHERE id = ?') .prepare('SELECT id, email, display_name, role, email_verified, totp_enabled FROM users WHERE id = ?')
.get(req.user.id); .get(req.user.id);
res.json({ user }); res.json({ user });
}); });
@@ -525,16 +525,17 @@ router.get('/trusted-devices', requireAuth, (req, res, next) => {
try { try {
const currentToken = req.headers['x-device-token'] || null; const currentToken = req.headers['x-device-token'] || null;
const devices = db.prepare( const devices = db.prepare(
"SELECT id, token, user_agent, ip_address, created_at, expires_at FROM two_fa_trusted_devices WHERE user_id=? AND expires_at > datetime('now') ORDER BY created_at DESC" "SELECT id, token, user_agent, ip_address, created_at, expires_at, last_seen_at FROM two_fa_trusted_devices WHERE user_id=? AND expires_at > datetime('now') ORDER BY created_at DESC"
).all(req.user.id); ).all(req.user.id);
const result = devices.map(d => ({ const result = devices.map(d => ({
id: d.id, id: d.id,
isCurrent: !!(currentToken && d.token === currentToken), isCurrent: !!(currentToken && d.token === currentToken),
user_agent: d.user_agent, user_agent: d.user_agent,
ip_address: d.ip_address, ip_address: d.ip_address,
created_at: d.created_at, created_at: d.created_at,
expires_at: d.expires_at, expires_at: d.expires_at,
last_seen_at: d.last_seen_at,
})); }));
res.json(result); res.json(result);
+21
View File
@@ -490,6 +490,22 @@ function SecurityForm() {
/* ── Appareils connectés ─────────────────────────────────────── */ /* ── Appareils connectés ─────────────────────────────────────── */
function fmtRelative(isoStr) {
if (!isoStr) return null;
// SQLite datetime() returns UTC without Z — force UTC parsing
const d = new Date(isoStr.endsWith('Z') ? isoStr : isoStr + 'Z');
const diff = Date.now() - d.getTime();
const secs = Math.floor(diff / 1000);
const mins = Math.floor(secs / 60);
const hours = Math.floor(mins / 60);
const days = Math.floor(hours / 24);
if (secs < 60) return `à l'instant`;
if (mins < 60) return `il y a ${mins} min`;
if (hours < 24) return `il y a ${hours} h`;
if (days < 30) return `il y a ${days} j`;
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function parseUA(ua) { function parseUA(ua) {
if (!ua) return { device: 'Inconnu', browser: '', icon: 'desktop' }; if (!ua) return { device: 'Inconnu', browser: '', icon: 'desktop' };
const isMobile = /mobile|android|iphone|ipad/i.test(ua); const isMobile = /mobile|android|iphone|ipad/i.test(ua);
@@ -625,6 +641,11 @@ function TrustedDevicesSection() {
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}> <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
{dev.ip_address && <span>{dev.ip_address} · </span>} {dev.ip_address && <span>{dev.ip_address} · </span>}
<span>Connecté le {connDate}</span> <span>Connecté le {connDate}</span>
{fmtRelative(dev.last_seen_at) && (
<span style={{ marginLeft: 8, color: 'var(--text-muted)', opacity: 0.75 }}>
· Vu {fmtRelative(dev.last_seen_at)}
</span>
)}
</div> </div>
</div> </div>
<button <button
@@ -82,6 +82,7 @@ export default function UsersSection({ currentUserId }) {
<th>Nom</th> <th>Nom</th>
<th>Email</th> <th>Email</th>
<th>Email vérifié</th> <th>Email vérifié</th>
<th>2FA</th>
<th>Rôle</th> <th>Rôle</th>
<th>Créé le</th> <th>Créé le</th>
<th>Actions</th> <th>Actions</th>
@@ -99,6 +100,12 @@ export default function UsersSection({ currentUserId }) {
: <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid #fcd34d' }}>En attente</span> : <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid #fcd34d' }}>En attente</span>
} }
</td> </td>
<td>
{u.totp_enabled
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--primary-bg, #eff6ff)', color: 'var(--primary, #1e40af)', border: '1px solid #bfdbfe' }}>🔐 Activé</span>
: <span style={{ fontSize: 11, color: 'var(--text-muted)' }}></span>
}
</td>
<td><Badge role={u.role} /></td> <td><Badge role={u.role} /></td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td> <td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
<td> <td>