Amélioration 2FA

This commit is contained in:
2026-06-14 22:13:33 +02:00
parent 13ec48a8f3
commit 402d690e96
4 changed files with 228 additions and 5 deletions
+3 -3
View File
@@ -5,9 +5,11 @@ const BASE = import.meta.env.VITE_API_URL || '/api';
function authHeaders() {
const token = localStorage.getItem('cl_token');
const investisseurId = localStorage.getItem('cl_investisseur_id');
const deviceToken = localStorage.getItem('cl_device_token');
const h = {};
if (token) h['Authorization'] = `Bearer ${token}`;
if (investisseurId) h['X-Investisseur-Id'] = investisseurId;
if (deviceToken) h['X-Device-Token'] = deviceToken;
return h;
}
@@ -63,6 +65,4 @@ export const api = {
}),
exportUrl: (path, params) => {
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
return BASE + path + qs;
},
};
+163 -1
View File
@@ -488,6 +488,168 @@ function SecurityForm() {
);
}
/* ── Appareils connectés ─────────────────────────────────────── */
function parseUA(ua) {
if (!ua) return { device: 'Inconnu', browser: '', icon: 'desktop' };
const isMobile = /mobile|android|iphone|ipad/i.test(ua);
let browser = 'Navigateur inconnu';
if (/Edg\//i.test(ua)) browser = 'Edge ' + (ua.match(/Edg\/([\d.]+)/)?.[1]?.split('.')[0] || '');
else if (/Chrome\//i.test(ua)) browser = 'Chrome ' + (ua.match(/Chrome\/([\d.]+)/)?.[1]?.split('.')[0] || '');
else if (/Firefox\//i.test(ua)) browser = 'Firefox ' + (ua.match(/Firefox\/([\d.]+)/)?.[1]?.split('.')[0] || '');
else if (/Safari\//i.test(ua)) browser = 'Safari ' + (ua.match(/Version\/([\d.]+)/)?.[1]?.split('.')[0] || '');
let os = 'Inconnu';
if (/Windows/i.test(ua)) os = 'Windows';
else if (/Macintosh/i.test(ua)) os = 'Mac';
else if (/Linux/i.test(ua)) os = 'Linux';
else if (/Android/i.test(ua)) os = 'Android';
else if (/iPhone|iPad/i.test(ua)) os = 'iOS';
return { device: os + (browser ? ' ' + browser : ''), icon: isMobile ? 'mobile' : 'desktop' };
}
function DeviceIcon({ type }) {
if (type === 'mobile') return (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2"/>
<line x1="12" y1="18" x2="12" y2="18" strokeWidth="2.5"/>
</svg>
);
return (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="4" width="20" height="14" rx="2"/>
<path d="M8 20h8M12 18v2"/>
</svg>
);
}
function TrustedDevicesSection() {
const [devices, setDevices] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(null); // id en cours de révocation
const currentToken = localStorage.getItem('cl_device_token');
const load = async () => {
try {
setLoading(true);
const data = await api.get('/auth/trusted-devices');
setDevices(data);
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const revoke = async (id, isCurrent) => {
setBusy(id);
try {
await api.del(`/auth/trusted-devices/${id}`);
if (isCurrent) localStorage.removeItem('cl_device_token');
await load();
} catch (e) { setErr(e.message); }
finally { setBusy(null); }
};
const revokeAll = async () => {
setBusy('all');
try {
await api.del('/auth/trusted-devices');
localStorage.removeItem('cl_device_token');
await load();
} catch (e) { setErr(e.message); }
finally { setBusy(null); }
};
if (loading) return null;
return (
<div className="card" style={{ marginTop: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<h3 style={{ margin: 0 }}>Appareils de confiance</h3>
{devices.length > 0 && (
<button
onClick={() => revokeAll()}
disabled={busy === 'all'}
style={{
padding: '5px 12px', borderRadius: 6, fontSize: 12, fontWeight: 600,
background: 'var(--danger,#dc2626)', color: '#fff', border: 'none',
cursor: busy === 'all' ? 'not-allowed' : 'pointer',
}}
>
Tout déconnecter
</button>
)}
</div>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Appareils reconnus pour la double authentification (validité 30 jours).
</p>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{devices.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Aucun appareil de confiance enregistré.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{devices.map(dev => {
const { device, icon } = parseUA(dev.user_agent);
const connDate = dev.created_at
? new Date(dev.created_at).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) + ' ' +
new Date(dev.created_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
: '';
return (
<div key={dev.id} style={{
display: 'flex', alignItems: 'center', gap: 16,
padding: '14px 16px', borderRadius: 10,
border: `1.5px solid ${dev.isCurrent ? 'var(--primary,#1e40af)' : 'var(--border)'}`,
background: dev.isCurrent ? 'var(--primary-bg,#eff6ff)' : 'var(--surface-2,#f9fafb)',
}}>
<div style={{
width: 52, height: 52, borderRadius: 10, flexShrink: 0,
background: 'var(--surface,#fff)', border: '1px solid var(--border)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--text-muted)',
}}>
<DeviceIcon type={icon} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text)' }}>{device}</span>
{dev.isCurrent && (
<span style={{
fontSize: 10, fontWeight: 700, padding: '2px 7px', borderRadius: 10, letterSpacing: 0.5,
background: 'var(--primary,#1e40af)', color: '#fff',
}}>APPAREIL ACTUEL</span>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>
{dev.ip_address && <span>{dev.ip_address} · </span>}
<span>Connecté le {connDate}</span>
</div>
</div>
<button
onClick={() => revoke(dev.id, dev.isCurrent)}
disabled={busy === dev.id}
style={{
flexShrink: 0, padding: '5px 12px', borderRadius: 6,
fontSize: 12, fontWeight: 600,
background: 'none',
color: 'var(--danger,#dc2626)',
border: '1px solid var(--danger,#dc2626)',
cursor: busy === dev.id ? 'not-allowed' : 'pointer',
}}
>
{busy === dev.id ? '' : 'Déconnecter'}
</button>
</div>
);
})}
</div>
)}
</div>
);
}
/* ── Page principale ─────────────────────────────────────────── */
export default function MonCompte() {
const { search } = useLocation();
@@ -522,7 +684,7 @@ export default function MonCompte() {
{/* ── Contenu ─────────────────────────────────────── */}
<div className="account-content">
{section === 'profil' && <AccountForm />}
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /></>}
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>}
</div>
</div>