Files
crowdlending-app/frontend/src/pages/Login.jsx
T

370 lines
20 KiB
React

import { useState, useEffect, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import { api } from '../api.js';
import AuthBgCol from '../components/AuthBgCol.jsx';
import PasswordInput from '../components/PasswordInput.jsx';
// ── Helpers ────────────────────────────────────────────────────────────────
const DEVICE_KEY = 'cl_device_token';
function fmtCountdown(sec) {
const m = Math.floor(sec / 60).toString().padStart(2, '0');
const s = (sec % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
// ── Header logo (colonne droite) ──────────────────────────────────────────
function AppHeader({ appInfo }) {
return (
<div className="login-mobile-header" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, marginBottom: 32 }}>
{appInfo.iconUrl && <img src={appInfo.iconUrl} alt={appInfo.appName} width={52} height={52} style={{ borderRadius: 12 }} />}
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', letterSpacing: '-0.4px' }}>{appInfo.appName}</span>
</div>
);
}
// ── Composant principal ────────────────────────────────────────────────────
export default function Login() {
const { login, completeLogin } = useAuth();
const navigate = useNavigate();
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true });
// Étape : 'form' | 'method' | 'code'
const [step, setStep] = useState('form');
const [sessionToken, setSessionToken] = useState(null);
const [method, setMethod] = useState(null); // 'totp' | 'email'
// Formulaire login
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [trustDev, setTrustDev] = useState(false);
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
const [unverified, setUnverified] = useState(false);
const [resendBusy, setResendBusy] = useState(false);
const [resendDone, setResendDone] = useState(false);
// Code 2FA
const [code, setCode] = useState('');
const [codeErr, setCodeErr] = useState(null);
const [codeBusy, setCodeBusy] = useState(false);
const [countdown, setCountdown] = useState(300); // 5 min pour email
const timerRef = useRef(null);
useEffect(() => {
fetch('/api/app-info').then(r => r.json()).then(d => setAppInfo(d)).catch(() => {});
}, []);
// Countdown pour OTP email
useEffect(() => {
if (step === 'code' && method === 'email') {
setCountdown(300);
timerRef.current = setInterval(() => {
setCountdown(c => {
if (c <= 1) { clearInterval(timerRef.current); return 0; }
return c - 1;
});
}, 1000);
}
return () => clearInterval(timerRef.current);
}, [step, method]);
// ── Soumission du formulaire ──────────────────────────────────────────────
const submit = async (e) => {
e.preventDefault();
setErr(null); setUnverified(false); setResendDone(false); setBusy(true);
try {
const deviceToken = localStorage.getItem(DEVICE_KEY) || undefined;
const result = await login(email, password, deviceToken);
if (result?.requires2FA) {
setSessionToken(result.sessionToken);
setStep('method');
} else {
navigate('/');
}
} catch (e) {
if (e.code === 'EMAIL_NOT_VERIFIED') {
setUnverified(true);
} else {
setErr(e.message || 'Identifiants incorrects.');
}
} finally { setBusy(false); }
};
const resend = async () => {
setResendBusy(true);
try {
await fetch('/api/auth/resend-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
setResendDone(true);
} catch (_) {}
finally { setResendBusy(false); }
};
// ── Choix de méthode 2FA ──────────────────────────────────────────────────
const chooseMethod = async (m) => {
setMethod(m);
setCodeErr(null);
setCode('');
if (m === 'email') {
try {
await api.post('/auth/2fa/send-email-code', { sessionToken });
} catch (e) {
setCodeErr(e.message);
return;
}
}
setStep('code');
};
// ── Vérification du code ──────────────────────────────────────────────────
const verifyCode = async (e) => {
e.preventDefault();
setCodeErr(null); setCodeBusy(true);
try {
const result = await api.post('/auth/2fa/verify', {
sessionToken, code, method, trustDevice: trustDev,
});
if (result.deviceToken) {
localStorage.setItem(DEVICE_KEY, result.deviceToken);
}
completeLogin(result.token, result.user);
navigate('/');
} catch (e) {
setCodeErr(e.message || 'Code invalide.');
} finally { setCodeBusy(false); }
};
const resendEmailCode = async () => {
setCodeErr(null);
try {
await api.post('/auth/2fa/send-email-code', { sessionToken });
setCountdown(300);
timerRef.current = setInterval(() => {
setCountdown(c => { if (c <= 1) { clearInterval(timerRef.current); return 0; } return c - 1; });
}, 1000);
} catch (e) { setCodeErr(e.message); }
};
// ── Rendu ─────────────────────────────────────────────────────────────────
const BtnPrimary = ({ children, disabled, onClick, type = 'submit' }) => (
<button type={type} disabled={disabled} onClick={onClick} style={{
width: '100%', padding: '11px 0',
background: disabled ? 'var(--text-muted)' : 'var(--primary, #1e40af)',
color: '#fff', border: 'none', borderRadius: 8,
fontSize: 15, fontWeight: 600,
cursor: disabled ? 'not-allowed' : 'pointer',
transition: 'background 0.15s',
}}>{children}</button>
);
const ErrBox = ({ msg }) => msg ? (
<div style={{ padding: '10px 14px', borderRadius: 8, fontSize: 14, background: 'var(--danger-bg, #fef2f2)', color: 'var(--danger, #dc2626)', border: '1px solid var(--danger-light, #fca5a5)' }}>
{msg}
</div>
) : null;
return (
<div style={{ display: 'flex', minHeight: '100dvh' }}>
<AuthBgCol appInfo={appInfo} />
<div style={{
flex: '1 1 50%', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
padding: '48px 24px', background: 'var(--background, #fff)', minWidth: 0,
}}>
<AppHeader appInfo={appInfo} />
<div style={{ width: '100%', maxWidth: 360 }}>
{/* ══ ÉTAPE 1 : Formulaire email/mot de passe ══════════════════ */}
{step === 'form' && (
<>
<div style={{ marginBottom: 28 }}>
<h1 style={{ margin: '0 0 6px', fontSize: 26, fontWeight: 700, letterSpacing: '-0.5px', color: 'var(--text)' }}>Bienvenue</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>Connectez-vous à votre compte</p>
</div>
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ErrBox msg={err} />
{unverified && (
<div style={{ padding: '12px 14px', borderRadius: 8, fontSize: 13, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid var(--warning-border, #fcd34d)', lineHeight: 1.5 }}>
<strong>Email non vérifié.</strong> Vérifiez votre boîte mail et cliquez sur le lien reçu.
<div style={{ marginTop: 8 }}>
{resendDone
? <span style={{ color: 'var(--success, #16a34a)', fontWeight: 500 }}> Email renvoyé !</span>
: <button type="button" onClick={() => resend()} disabled={resendBusy} style={{ background: 'none', border: 'none', padding: 0, color: 'var(--primary, #1e40af)', fontWeight: 600, fontSize: 13, cursor: 'pointer', textDecoration: 'underline' }}>
{resendBusy ? 'Envoi…' : "Renvoyer l'email de vérification"}
</button>
}
</div>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Adresse email</label>
<input className="form-input" type="email" required autoComplete="email" placeholder="vous@exemple.com"
value={email} onChange={e => setEmail(e.target.value)} style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Mot de passe</label>
<Link to="/forgot-password" style={{ fontSize: 13, color: 'var(--text-muted)', textDecoration: 'underline' }}>Mot de passe oublié ?</Link>
</div>
<PasswordInput className="form-input" required autoComplete="current-password" placeholder="••••••••"
value={password} onChange={e => setPassword(e.target.value)} style={{ width: '100%' }} />
</div>
<BtnPrimary disabled={busy}>{busy ? 'Connexion…' : 'Se connecter'}</BtnPrimary>
</form>
{appInfo.allowRegistration !== false && (
<p style={{ marginTop: 24, textAlign: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
Pas encore de compte ?{' '}
<Link to="/register" style={{ color: 'var(--text)', fontWeight: 500, textDecoration: 'underline' }}>Créer un compte</Link>
</p>
)}
</>
)}
{/* ══ ÉTAPE 2 : Choix de méthode 2FA ══════════════════════════ */}
{step === 'method' && (
<>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<div style={{ width: 52, height: 52, borderRadius: '50%', background: 'var(--primary-bg, #eff6ff)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect x="5" y="11" width="14" height="10" rx="2" stroke="var(--primary,#1e40af)" strokeWidth="2"/>
<path d="M8 11V7a4 4 0 018 0v4" stroke="var(--primary,#1e40af)" strokeWidth="2" strokeLinecap="round"/>
<circle cx="12" cy="16" r="1.5" fill="var(--primary,#1e40af)"/>
</svg>
</div>
<h1 style={{ margin: '0 0 6px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>Vérification en deux étapes</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>Choisissez votre méthode de vérification</p>
</div>
<ErrBox msg={codeErr} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: codeErr ? 16 : 0 }}>
<button onClick={() => chooseMethod('totp')} style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 16px', borderRadius: 10,
border: '1.5px solid var(--border)', background: 'var(--surface-2, #f9fafb)',
cursor: 'pointer', textAlign: 'left', transition: 'border-color 0.15s',
}} onMouseEnter={e => e.currentTarget.style.borderColor='var(--primary,#1e40af)'}
onMouseLeave={e => e.currentTarget.style.borderColor='var(--border)'}>
<div style={{ width: 40, height: 40, borderRadius: 8, background: 'var(--primary-bg,#eff6ff)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<rect x="7" y="2" width="10" height="20" rx="2" stroke="var(--primary,#1e40af)" strokeWidth="2"/>
<line x1="12" y1="18" x2="12" y2="18" stroke="var(--primary,#1e40af)" strokeWidth="2.5" strokeLinecap="round"/>
</svg>
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Application d'authentification</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Google Authenticator, Authy, etc.</div>
</div>
</button>
<button onClick={() => chooseMethod('email')} style={{
display: 'flex', alignItems: 'center', gap: 14,
padding: '14px 16px', borderRadius: 10,
border: '1.5px solid var(--border)', background: 'var(--surface-2, #f9fafb)',
cursor: 'pointer', textAlign: 'left', transition: 'border-color 0.15s',
}} onMouseEnter={e => e.currentTarget.style.borderColor='var(--primary,#1e40af)'}
onMouseLeave={e => e.currentTarget.style.borderColor='var(--border)'}>
<div style={{ width: 40, height: 40, borderRadius: 8, background: 'var(--primary-bg,#eff6ff)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" stroke="var(--primary,#1e40af)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>Code par email</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Recevoir un code à {email}</div>
</div>
</button>
</div>
<button type="button" onClick={() => { setStep('form'); setErr(null); }} style={{ marginTop: 24, width: '100%', background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline' }}>
← Retour à la connexion
</button>
</>
)}
{/* ══ ÉTAPE 3 : Saisie du code ═════════════════════════════════ */}
{step === 'code' && (
<>
<div style={{ textAlign: 'center', marginBottom: 28 }}>
<div style={{ width: 52, height: 52, borderRadius: '50%', background: 'var(--primary-bg,#eff6ff)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
{method === 'totp'
? <svg width="24" height="24" viewBox="0 0 24 24" fill="none"><rect x="7" y="2" width="10" height="20" rx="2" stroke="var(--primary,#1e40af)" strokeWidth="2"/><line x1="12" y1="18" x2="12" y2="18" stroke="var(--primary,#1e40af)" strokeWidth="2.5" strokeLinecap="round"/></svg>
: <svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" stroke="var(--primary,#1e40af)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/></svg>
}
</div>
<h1 style={{ margin: '0 0 6px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>
{method === 'totp' ? 'Code de l\'application' : 'Code par email'}
</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 13, lineHeight: 1.5 }}>
{method === 'totp'
? 'Entrez le code à 6 chiffres affiché dans votre application d\'authentification.'
: <>Code envoyé à <strong>{email}</strong>.</>
}
</p>
</div>
<form onSubmit={verifyCode} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ErrBox msg={codeErr} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Code de vérification</label>
{method === 'email' && (
<span style={{ fontSize: 12, fontWeight: 600, color: countdown > 0 ? 'var(--text-muted)' : 'var(--danger,#dc2626)' }}>
{countdown > 0 ? fmtCountdown(countdown) : 'Expiré'}
</span>
)}
</div>
<input
className="form-input"
type="text" inputMode="numeric" pattern="[0-9]{6}"
maxLength={6} autoComplete="one-time-code"
placeholder="000000"
value={code} onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
style={{ width: '100%', fontSize: 24, letterSpacing: 8, textAlign: 'center', fontWeight: 700 }}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--text-muted)', cursor: 'pointer' }}>
<input type="checkbox" checked={trustDev} onChange={e => setTrustDev(e.target.checked)} style={{ width: 'auto' }} />
Faire confiance à cet appareil pendant 30 jours
</label>
<BtnPrimary disabled={codeBusy || code.length !== 6}>
{codeBusy ? 'Vérification…' : 'Vérifier'}
</BtnPrimary>
{method === 'email' && countdown === 0 && (
<button type="button" onClick={resendEmailCode} style={{ background: 'none', border: 'none', color: 'var(--primary,#1e40af)', fontWeight: 600, fontSize: 13, cursor: 'pointer', textDecoration: 'underline' }}>
Renvoyer le code
</button>
)}
</form>
<button type="button" onClick={() => { setStep('method'); setCode(''); setCodeErr(null); clearInterval(timerRef.current); }} style={{ marginTop: 20, width: '100%', background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline' }}>
Changer de méthode
</button>
</>
)}
</div>
</div>
</div>
);
}