diff --git a/backend/src/db/index.js b/backend/src/db/index.js index a5f734b..0061486 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -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; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index ea9fcda..50857a0 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -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' }); diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 2643462..9f7b918 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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: ` -

Votre configuration SMTP fonctionne correctement.

-

Email de test envoyé le ${date}.

- `, - 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; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 7e59753..f58a202 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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); diff --git a/frontend/src/pages/MonCompte.jsx b/frontend/src/pages/MonCompte.jsx index 1f44368..7aa1171 100644 --- a/frontend/src/pages/MonCompte.jsx +++ b/frontend/src/pages/MonCompte.jsx @@ -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() {
{dev.ip_address && {dev.ip_address} · } Connecté le {connDate} + {fmtRelative(dev.last_seen_at) && ( + + · Vu {fmtRelative(dev.last_seen_at)} + + )}