Fix 2FA
This commit is contained in:
@@ -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');
|
||||
|
||||
export default db;
|
||||
|
||||
@@ -18,6 +18,13 @@ export function requireAuth(req, res, next) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
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();
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
|
||||
+2
-146
@@ -48,7 +48,7 @@ const router = Router();
|
||||
/** Liste tous les utilisateurs */
|
||||
router.get('/users', (req, res) => {
|
||||
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
|
||||
ORDER BY id ASC
|
||||
`).all();
|
||||
@@ -291,7 +291,6 @@ router.post('/jobs/:name/run', (req, res, next) => {
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
/* ── 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); }
|
||||
});
|
||||
|
||||
/* ── 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 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) });
|
||||
}
|
||||
});
|
||||
export default router;
|
||||
|
||||
@@ -155,7 +155,7 @@ router.post('/login', async (req, res, next) => {
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
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);
|
||||
res.json({ user });
|
||||
});
|
||||
@@ -525,16 +525,17 @@ router.get('/trusted-devices', requireAuth, (req, res, next) => {
|
||||
try {
|
||||
const currentToken = req.headers['x-device-token'] || null;
|
||||
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);
|
||||
|
||||
const result = devices.map(d => ({
|
||||
id: d.id,
|
||||
isCurrent: !!(currentToken && d.token === currentToken),
|
||||
user_agent: d.user_agent,
|
||||
ip_address: d.ip_address,
|
||||
created_at: d.created_at,
|
||||
expires_at: d.expires_at,
|
||||
id: d.id,
|
||||
isCurrent: !!(currentToken && d.token === currentToken),
|
||||
user_agent: d.user_agent,
|
||||
ip_address: d.ip_address,
|
||||
created_at: d.created_at,
|
||||
expires_at: d.expires_at,
|
||||
last_seen_at: d.last_seen_at,
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
|
||||
@@ -490,6 +490,22 @@ function SecurityForm() {
|
||||
|
||||
|
||||
/* ── 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) {
|
||||
if (!ua) return { device: 'Inconnu', browser: '', icon: 'desktop' };
|
||||
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 }}>
|
||||
{dev.ip_address && <span>{dev.ip_address} · </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>
|
||||
<button
|
||||
|
||||
@@ -82,6 +82,7 @@ export default function UsersSection({ currentUserId }) {
|
||||
<th>Nom</th>
|
||||
<th>Email</th>
|
||||
<th>Email vérifié</th>
|
||||
<th>2FA</th>
|
||||
<th>Rôle</th>
|
||||
<th>Créé le</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>
|
||||
}
|
||||
</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 style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
|
||||
<td>
|
||||
|
||||
Reference in New Issue
Block a user