Amélioration 2FA
This commit is contained in:
+3
-3
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user