Modification des pages d'authentification

This commit is contained in:
2026-06-14 21:58:05 +02:00
parent dc28b12c27
commit 9f6363ec20
20 changed files with 2494 additions and 172 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+8 -2
View File
@@ -2,6 +2,9 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './context/AuthContext.jsx';
import Login from './pages/Login.jsx';
import Register from './pages/Register.jsx';
import ForgotPassword from './pages/ForgotPassword.jsx';
import ResetPassword from './pages/ResetPassword.jsx';
import VerifyEmail from './pages/VerifyEmail.jsx';
import Layout from './components/Layout.jsx';
import Dashboard from './pages/Dashboard.jsx';
import DepotsRetraits from './pages/DepotsRetraits.jsx';
@@ -36,8 +39,11 @@ function AdminOnly({ children }) {
export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route element={<Protected><Layout /></Protected>}>
<Route index element={<Dashboard />} />
<Route path="plateformes" element={<Plateformes />} />
+2 -1
View File
@@ -19,7 +19,8 @@ async function handle(res) {
if (!res.ok) {
const msg = (body && body.error) || res.statusText || 'Request failed';
const err = new Error(msg);
err.status = res.status;
err.status = res.status;
err.code = body && body.code;
err.details = body && body.details;
throw err;
}
+19 -3
View File
@@ -17,16 +17,32 @@ export function AuthProvider({ children }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const login = async (email, password) => {
const r = await api.post('/auth/login', { email, password });
const login = async (email, password, deviceToken) => {
const body = { email, password, ...(deviceToken ? { deviceToken } : {}) };
const r = await api.post('/auth/login', body);
if (r.requires2FA) {
// Authentification incomplète — retourne les infos pour la page Login
return { requires2FA: true, sessionToken: r.sessionToken, email: r.email };
}
localStorage.setItem('cl_token', r.token);
setToken(r.token);
setUser(r.user);
return r.user;
};
// Appelé par Login après validation du code 2FA
const completeLogin = (token, userData) => {
localStorage.setItem('cl_token', token);
setToken(token);
setUser(userData);
};
const register = async (email, password, displayName) => {
const r = await api.post('/auth/register', { email, password, displayName });
if (r.requiresVerification) {
// Compte créé mais email non vérifié — pas de connexion automatique
return { requiresVerification: true, email: r.email };
}
localStorage.setItem('cl_token', r.token);
setToken(r.token);
setUser(r.user);
@@ -54,7 +70,7 @@ export function AuthProvider({ children }) {
const isAdmin = user?.role === 'admin';
return (
<AuthCtx.Provider value={{ token, user, loading, login, register, logout, updateUser, isAdmin }}>
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, isAdmin }}>
{children}
</AuthCtx.Provider>
);
+223
View File
@@ -0,0 +1,223 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
export default function ForgotPassword() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState(null); // null | 'sent' | 'error'
const [errMsg, setErrMsg] = useState('');
const [busy, setBusy] = useState(false);
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null });
useEffect(() => {
fetch('/api/app-info')
.then(r => r.json())
.then(d => setAppInfo(d))
.catch(() => {});
}, []);
const submit = async (e) => {
e.preventDefault();
setErrMsg(''); setBusy(true);
try {
const res = await fetch('/api/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await res.json();
if (!res.ok) { setErrMsg(data.error || 'Une erreur est survenue.'); setStatus('error'); }
else setStatus('sent');
} catch {
setErrMsg('Impossible de joindre le serveur.'); setStatus('error');
} finally { setBusy(false); }
};
return (
<div style={{ display: 'flex', minHeight: '100dvh' }}>
{/* ── Colonne gauche — image ───────────────────────────── */}
<div className="login-bg-col" style={{
flex: '1 1 50%',
display: 'none',
position: 'relative',
overflow: 'hidden',
background: '#0d0d0d',
}}>
<img
src="/login-bg.jpg"
alt=""
aria-hidden="true"
onError={e => { e.target.style.display = 'none'; }}
style={{
position: 'absolute', inset: 0,
width: '100%', height: '100%',
objectFit: 'cover', opacity: 0.85,
}}
/>
<div style={{
position: 'absolute', bottom: 40, left: 40,
color: '#fff',
display: 'flex', alignItems: 'center', gap: 12,
}}>
{appInfo.iconUrl && (
<img src={appInfo.iconUrl} alt="" width={36} height={36}
style={{ borderRadius: 8, flexShrink: 0 }} />
)}
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px' }}>
{appInfo.appName}
</span>
</div>
</div>
{/* ── Colonne droite — formulaire ──────────────────────── */}
<div style={{
flex: '1 1 50%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 24px',
background: 'var(--background, #fff)',
minWidth: 0,
}}>
{/* Logo + nom */}
<div 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>
<div style={{ width: '100%', maxWidth: 360 }}>
{status === 'sent' ? (
/* ── Confirmation envoi ── */
<div style={{ textAlign: 'center' }}>
<div style={{
width: 52, height: 52, borderRadius: '50%',
background: 'var(--success-bg, #f0fdf4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 20px',
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17l-5-5" stroke="var(--success, #16a34a)" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h1 style={{
margin: '0 0 10px', fontSize: 22, fontWeight: 700,
letterSpacing: '-0.4px', color: 'var(--text)',
}}>Email envoyé</h1>
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
Si un compte correspond à <strong>{email}</strong>, vous recevrez
un lien de réinitialisation valable <strong>1 heure</strong>.
</p>
<Link to="/login" style={{
display: 'block', width: '100%', padding: '11px 0',
background: 'var(--primary, #1e40af)', color: '#fff',
borderRadius: 8, fontSize: 15, fontWeight: 600,
textDecoration: 'none', textAlign: 'center',
}}>
Retour à la connexion
</Link>
</div>
) : (
/* ── Formulaire ── */
<>
<div style={{ marginBottom: 28 }}>
<h1 style={{
margin: '0 0 6px', fontSize: 26, fontWeight: 700,
letterSpacing: '-0.5px', color: 'var(--text)',
}}>
Mot de passe oublié ?
</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>
Saisissez votre email pour recevoir un lien de réinitialisation.
</p>
</div>
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{status === 'error' && (
<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)',
}}>
{errMsg}
</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>
<button
type="submit"
disabled={busy}
style={{
marginTop: 4, width: '100%',
padding: '11px 0',
background: busy ? 'var(--text-muted)' : 'var(--primary, #1e40af)',
color: '#fff',
border: 'none', borderRadius: 8,
fontSize: 15, fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
transition: 'background 0.15s',
}}
>
{busy ? 'Envoi…' : 'Envoyer les instructions'}
</button>
</form>
<p style={{
marginTop: 24, textAlign: 'center',
fontSize: 13, color: 'var(--text-muted)',
}}>
Vous vous souvenez ?{' '}
<Link to="/login" style={{
color: 'var(--text)', fontWeight: 500, textDecoration: 'underline',
}}>
Se connecter
</Link>
</p>
</>
)}
</div>
</div>
<style>{`
@media (min-width: 768px) {
.login-bg-col { display: block !important; }
}
`}</style>
</div>
);
}
+378 -35
View File
@@ -1,44 +1,387 @@
import { useState } from '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';
export default function Login() {
const { login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
// ── Helpers ────────────────────────────────────────────────────────────────
const DEVICE_KEY = 'cl_device_token';
const submit = async (e) => {
e.preventDefault();
setErr(null); setBusy(true);
try {
await login(email, password);
navigate('/');
} catch (e) {
setErr(e.message);
} finally { setBusy(false); }
};
function fmtCountdown(sec) {
const m = Math.floor(sec / 60).toString().padStart(2, '0');
const s = (sec % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
// ── Colonne image gauche (partagée) ───────────────────────────────────────
function BgCol({ appInfo }) {
return (
<div className="login-shell">
<form className="card login-card" onSubmit={submit}>
<h2 style={{ marginTop: 0 }}>Connexion</h2>
{err && <div className="error">{err}</div>}
<label>Email</label>
<input type="email" required value={email} onChange={e => setEmail(e.target.value)} />
<div style={{ height: 10 }} />
<label>Mot de passe</label>
<input type="password" required value={password} onChange={e => setPassword(e.target.value)} />
<div style={{ height: 16 }} />
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
{busy ? '…' : 'Se connecter'}
</button>
<p className="text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
Pas encore de compte ? <Link to="/register">Créer un compte</Link>
</p>
</form>
<div className="login-bg-col" style={{
flex: '1 1 50%', display: 'none',
position: 'relative', overflow: 'hidden', background: '#0d0d0d',
}}>
<img src="/login-bg.jpg" alt="" aria-hidden="true"
onError={e => { e.target.style.display = 'none'; }}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }}
/>
<div style={{ position: 'absolute', bottom: 40, left: 40, color: '#fff', display: 'flex', alignItems: 'center', gap: 12 }}>
{appInfo.iconUrl && <img src={appInfo.iconUrl} alt="" width={36} height={36} style={{ borderRadius: 8, flexShrink: 0 }} />}
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px' }}>{appInfo.appName}</span>
</div>
</div>
);
}
// ── 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 });
// É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' }}>
<BgCol 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>
<input className="form-input" type="password" 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>
<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>
<style>{`
@media (min-width: 768px) { .login-bg-col { display: block !important; } }
`}</style>
</div>
);
}
+251 -7
View File
@@ -76,6 +76,84 @@ function ProfileSelect({ label, options, value, onChange }) {
);
}
/* ── Bloc renvoi email de vérification ───────────────────────── */
function EmailResendBlock({ email }) {
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const resend = async () => {
setBusy(true);
try {
await fetch('/api/auth/resend-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
setDone(true);
} catch (_) {}
finally { setBusy(false); }
};
if (done) return <p style={{ fontSize: 12, color: 'var(--success, #16a34a)', marginTop: 6 }}> Email de vérification renvoyé.</p>;
return (
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 6 }}>
Email non confirmé.{' '}
<button type="button" onClick={() => resend()} disabled={busy}
style={{ background: 'none', border: 'none', padding: 0, color: 'var(--primary)', fontWeight: 600, fontSize: 12, cursor: 'pointer', textDecoration: 'underline' }}>
{busy ? 'Envoi…' : 'Renvoyer le lien'}
</button>
</p>
);
}
/* ── Changement d'email ───────────────────────────────────────── */
function EmailChangeForm({ onDone }) {
const { updateUser } = useAuth();
const [newEmail, setNewEmail] = useState('');
const [pwd, setPwd] = useState('');
const [msg, setMsg] = useState(null);
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
const submit = async (e) => {
e.preventDefault();
setErr(null); setMsg(null); setBusy(true);
try {
const r = await updateUser({ email: newEmail, currentPassword: pwd });
if (r?.requiresVerification) {
setMsg(`Un email de vérification a été envoyé à ${newEmail}. Validez-le pour activer cette adresse.`);
} else {
setMsg('Email mis à jour.'); setTimeout(onDone, 1500);
}
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
return (
<form onSubmit={submit} style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)' }}>
Après le changement, un email de vérification sera envoyé à la nouvelle adresse.
</p>
{err && <div className="error">{err}</div>}
{msg && <div className="success-msg">{msg}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label className="profile-label">Nouvelle adresse email</label>
<input className="profile-input" type="email" required value={newEmail} onChange={e => setNewEmail(e.target.value)} placeholder="nouvelle@email.com" />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<label className="profile-label">Mot de passe actuel (confirmation)</label>
<input className="profile-input" type="password" required value={pwd} onChange={e => setPwd(e.target.value)} placeholder="••••••••" />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" className="btn btn-primary" disabled={busy} style={{ fontSize: 13 }}>
{busy ? 'Enregistrement…' : 'Confirmer le changement'}
</button>
<button type="button" className="btn btn-outline" onClick={() => onDone()} style={{ fontSize: 13 }}>Annuler</button>
</div>
</form>
);
}
/* ── Mon profil + Préférences ────────────────────────────────── */
function AccountForm() {
const { user, updateUser } = useAuth();
@@ -92,9 +170,10 @@ function AccountForm() {
const initial = parseName(user?.display_name);
const [prenom, setPrenom] = useState(initial.prenom);
const [nom, setNom] = useState(initial.nom);
const [infoMsg, setInfoMsg] = useState(null);
const [infoErr, setInfoErr] = useState(null);
const [loading, setLoading] = useState(false);
const [infoMsg, setInfoMsg] = useState(null);
const [infoErr, setInfoErr] = useState(null);
const [loading, setLoading] = useState(false);
const [changingEmail, setChangingEmail] = useState(false);
const save = async () => {
setInfoErr(null); setInfoMsg(null); setLoading(true);
@@ -138,16 +217,23 @@ function AccountForm() {
<div className="profile-field profile-field-full">
<span className="profile-label">Mon email</span>
<div className="profile-email-row">
<div className="profile-email-row" style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span className="profile-email-value">{user?.email}</span>
{user?.email_verified
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--success-bg, #f0fdf4)', color: 'var(--success, #16a34a)', border: '1px solid #bbf7d0' }}> Vérifié</span>
: <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' }}>Non vérifié</span>
}
</div>
{!user?.email_verified && <EmailResendBlock email={user?.email} />}
</div>
<div style={{ marginTop: 20 }}>
<button className="profile-manage-btn" type="button" disabled>
Gérer mon email
<button className="profile-manage-btn" type="button" onClick={() => setChangingEmail(v => !v)}>
{changingEmail ? 'Annuler' : 'Changer mon email'}
</button>
</div>
{changingEmail && <EmailChangeForm onDone={() => setChangingEmail(false)} />}
</section>
{/* ── Préférences ─────────────────────────────────────── */}
@@ -164,6 +250,163 @@ function AccountForm() {
);
}
/* ── 2FA — Configuration double authentification ───────────────── */
function TwoFASection({ user }) {
const [status, setStatus] = useState('idle'); // idle | setup | confirming | disabling
const [setupData, setSetupData] = useState(null); // { secret, qrCode, email }
const [code, setCode] = useState('');
const [disablePwd, setDisablePwd] = useState('');
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState(null);
const [enabled, setEnabled] = useState(!!user?.totp_enabled);
const startSetup = async () => {
setErr(null); setMsg(null); setBusy(true);
try {
const data = await api.get('/auth/2fa/setup');
setSetupData(data);
setStatus('setup');
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
const confirmSetup = async (e) => {
e.preventDefault(); setErr(null); setBusy(true);
try {
await api.post('/auth/2fa/confirm-setup', { code });
setEnabled(true);
setStatus('idle');
setSetupData(null);
setCode('');
setMsg('Le 2FA est maintenant activé sur votre compte.');
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
const disable = async (e) => {
e.preventDefault(); setErr(null); setBusy(true);
try {
await api.post('/auth/2fa/disable', { password: disablePwd });
setEnabled(false);
setStatus('idle');
setDisablePwd('');
setMsg('Le 2FA a été désactivé.');
localStorage.removeItem('cl_device_token');
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
return (
<div className="card" style={{ marginTop: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 4 }}>
<h3 style={{ margin: 0 }}>Double authentification (2FA)</h3>
<span style={{
fontSize: 11, fontWeight: 700, padding: '3px 10px', borderRadius: 12,
background: enabled ? 'var(--success-bg,#f0fdf4)' : 'var(--surface-2,#f4f4f5)',
color: enabled ? 'var(--success,#16a34a)' : 'var(--text-muted)',
border: `1px solid ${enabled ? '#bbf7d0' : 'var(--border)'}`,
}}>
{enabled ? '✓ Activé' : 'Désactivé'}
</span>
</div>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Protégez votre compte avec une vérification supplémentaire à chaque connexion.
</p>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{msg && <div className="success-msg" style={{ marginBottom: 12 }}>{msg}</div>}
{/* ── État normal ── */}
{status === 'idle' && !enabled && (
<button className="primary" onClick={() => startSetup()} disabled={busy}>
{busy ? 'Chargement…' : 'Activer le 2FA'}
</button>
)}
{status === 'idle' && enabled && (
<button className="btn btn-outline" style={{ color: 'var(--danger,#dc2626)', borderColor: 'var(--danger,#dc2626)' }}
onClick={() => { setStatus('disabling'); setErr(null); setMsg(null); }}>
Désactiver le 2FA
</button>
)}
{/* ── Setup : QR code + vérification ── */}
{status === 'setup' && setupData && (
<div>
<p style={{ margin: '0 0 12px', fontSize: 13, lineHeight: 1.6 }}>
Scannez ce QR code avec votre application d'authentification (<strong>Google Authenticator</strong>, <strong>Authy</strong>, etc.), puis entrez le code à 6 chiffres pour confirmer.
</p>
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start', flexWrap: 'wrap', marginBottom: 20 }}>
<div style={{ background: '#fff', padding: 12, borderRadius: 8, border: '1px solid var(--border)', display: 'inline-block' }}>
<img src={setupData.qrCode} alt="QR code 2FA" width={160} height={160} />
</div>
<div style={{ flex: 1, minWidth: 200 }}>
<p style={{ margin: '0 0 6px', fontSize: 12, color: 'var(--text-muted)' }}>
Vous ne pouvez pas scanner ? Entrez manuellement cette clé dans votre application :
</p>
<code style={{
display: 'block', background: 'var(--surface-2)', padding: '8px 10px',
borderRadius: 6, fontSize: 13, letterSpacing: 1, wordBreak: 'break-all',
border: '1px solid var(--border)',
}}>
{setupData.secret}
</code>
<p style={{ margin: '8px 0 0', fontSize: 11, color: 'var(--text-muted)' }}>
Émetteur : <strong>{setupData.issuer}</strong> · Compte : <strong>{setupData.email}</strong>
</p>
</div>
</div>
<form onSubmit={confirmSetup} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', maxWidth: 320 }}>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 13, fontWeight: 500 }}>Code de vérification</label>
<input
type="text" inputMode="numeric" maxLength={6} pattern="[0-9]{6}"
placeholder="000000" value={code}
onChange={e => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
autoComplete="one-time-code"
style={{ fontSize: 20, letterSpacing: 6, textAlign: 'center', fontWeight: 700 }}
/>
</div>
<button className="primary" type="submit" disabled={busy || code.length !== 6} style={{ flexShrink: 0 }}>
{busy ? '' : 'Confirmer'}
</button>
</form>
<button type="button" onClick={() => { setStatus('idle'); setSetupData(null); setCode(''); setErr(null); }}
style={{ marginTop: 12, background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>
Annuler
</button>
</div>
)}
{/* ── Désactivation : mot de passe ── */}
{status === 'disabling' && (
<form onSubmit={disable} style={{ maxWidth: 360 }}>
<p style={{ margin: '0 0 12px', fontSize: 13, color: 'var(--text-muted)' }}>
Confirmez votre mot de passe pour désactiver le 2FA.
</p>
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: 4, fontSize: 13, fontWeight: 500 }}>Mot de passe actuel</label>
<input type="password" required value={disablePwd}
onChange={e => setDisablePwd(e.target.value)}
autoComplete="current-password" placeholder="••••••••" />
</div>
<button className="btn" style={{ background: 'var(--danger,#dc2626)', color: '#fff', flexShrink: 0 }}
type="submit" disabled={busy || !disablePwd}>
{busy ? '' : 'Désactiver'}
</button>
</div>
<button type="button" onClick={() => { setStatus('idle'); setErr(null); setDisablePwd(''); }}
style={{ marginTop: 10, background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>
Annuler
</button>
</form>
)}
</div>
);
}
/* ── Sécurité — Mot de passe ─────────────────────────────────── */
function SecurityForm() {
const { updateUser } = useAuth();
@@ -249,6 +492,7 @@ function SecurityForm() {
export default function MonCompte() {
const { search } = useLocation();
const navigate = useNavigate();
const { user } = useAuth();
const section = new URLSearchParams(search).get('section') || 'profil';
const setSection = (s) => navigate(`/compte?section=${s}`, { replace: true });
@@ -278,7 +522,7 @@ export default function MonCompte() {
{/* ── Contenu ─────────────────────────────────────── */}
<div className="account-content">
{section === 'profil' && <AccountForm />}
{section === 'securite' && <SecurityForm />}
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /></>}
</div>
</div>
+240 -23
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
@@ -8,40 +8,257 @@ export default function Register() {
const [form, setForm] = useState({ email: '', password: '', displayName: '' });
const [err, setErr] = useState(null);
const [busy, setBusy] = useState(false);
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null });
useEffect(() => {
fetch('/api/app-info')
.then(r => r.json())
.then(d => setAppInfo(d))
.catch(() => {});
}, []);
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value });
const [verifyEmail, setVerifyEmail] = useState(null); // email à vérifier
const submit = async (e) => {
e.preventDefault();
setErr(null); setBusy(true);
try {
await register(form.email, form.password, form.displayName || undefined);
navigate('/');
const result = await register(form.email, form.password, form.displayName || undefined);
if (result?.requiresVerification) {
setVerifyEmail(result.email);
} else {
navigate('/');
}
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
return (
<div className="login-shell">
<form className="card login-card" onSubmit={submit}>
<h2 style={{ marginTop: 0 }}>Créer un compte</h2>
{err && <div className="error">{err}</div>}
<label>Nom d'affichage</label>
<input value={form.displayName} onChange={set('displayName')} placeholder="Olivier" />
<div style={{ height: 10 }} />
<label>Email</label>
<input type="email" required value={form.email} onChange={set('email')} />
<div style={{ height: 10 }} />
<label>Mot de passe (8 car. min.)</label>
<input type="password" required minLength={8} value={form.password} onChange={set('password')} />
<div style={{ height: 16 }} />
<button className="primary" type="submit" disabled={busy} style={{ width: '100%' }}>
{busy ? '' : 'Créer le compte'}
</button>
<p className="text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
Déjà inscrit ? <Link to="/login">Se connecter</Link>
</p>
</form>
<div style={{ display: 'flex', minHeight: '100dvh' }}>
{/* ── Colonne gauche — image ───────────────────────────── */}
<div className="login-bg-col" style={{
flex: '1 1 50%',
display: 'none',
position: 'relative',
overflow: 'hidden',
background: '#0d0d0d',
}}>
<img
src="/login-bg.jpg"
alt=""
aria-hidden="true"
onError={e => { e.target.style.display = 'none'; }}
style={{
position: 'absolute', inset: 0,
width: '100%', height: '100%',
objectFit: 'cover', opacity: 0.85,
}}
/>
<div style={{
position: 'absolute', bottom: 40, left: 40,
color: '#fff',
display: 'flex', alignItems: 'center', gap: 12,
}}>
{appInfo.iconUrl && (
<img src={appInfo.iconUrl} alt="" width={36} height={36}
style={{ borderRadius: 8, flexShrink: 0 }} />
)}
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px' }}>
{appInfo.appName}
</span>
</div>
</div>
{/* ── Colonne droite — formulaire ──────────────────────── */}
<div style={{
flex: '1 1 50%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 24px',
background: 'var(--background, #fff)',
minWidth: 0,
}}>
{/* Logo + nom */}
<div 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>
<div style={{ width: '100%', maxWidth: 360 }}>
{/* ── Écran vérification email ── */}
{verifyEmail ? (
<div style={{ textAlign: 'center' }}>
<div style={{
width: 56, height: 56, borderRadius: '50%',
background: 'var(--primary-bg, #eff6ff)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 20px',
}}>
<svg width="26" height="26" 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 10px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>
Vérifiez votre email
</h1>
<p style={{ margin: '0 0 6px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
Un email de vérification a été envoyé à
</p>
<p style={{ margin: '0 0 24px', fontWeight: 600, color: 'var(--text)', fontSize: 14 }}>
{verifyEmail}
</p>
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 13, lineHeight: 1.6 }}>
Cliquez sur le lien dans l'email pour activer votre compte. Vérifiez aussi vos spams.
</p>
<Link to="/login" style={{
display: 'block', width: '100%', padding: '11px 0',
background: 'var(--primary, #1e40af)', color: '#fff',
borderRadius: 8, fontSize: 15, fontWeight: 600,
textDecoration: 'none', textAlign: 'center',
}}>
Retour à la connexion
</Link>
</div>
) : (
<>
{/* En-tête */}
<div style={{ marginBottom: 28 }}>
<h1 style={{
margin: '0 0 6px',
fontSize: 26, fontWeight: 700,
letterSpacing: '-0.5px',
color: 'var(--text)',
}}>
Créer un compte
</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>
Rejoignez {appInfo.appName}
</p>
</div>
{/* Formulaire */}
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{err && (
<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)',
}}>
{err}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
Nom d'affichage
</label>
<input
className="form-input"
type="text"
autoComplete="name"
placeholder="Olivier"
value={form.displayName}
onChange={set('displayName')}
style={{ width: '100%' }}
/>
</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={form.email}
onChange={set('email')}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
Mot de passe <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>(8 car. min.)</span>
</label>
<input
className="form-input"
type="password" required
minLength={8}
autoComplete="new-password"
placeholder="••••••••"
value={form.password}
onChange={set('password')}
style={{ width: '100%' }}
/>
</div>
<button
type="submit"
disabled={busy}
style={{
marginTop: 4, width: '100%',
padding: '11px 0',
background: busy ? 'var(--text-muted)' : 'var(--primary, #1e40af)',
color: '#fff',
border: 'none', borderRadius: 8,
fontSize: 15, fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
transition: 'background 0.15s',
}}
>
{busy ? 'Création…' : 'Créer le compte'}
</button>
</form>
<p style={{
marginTop: 24, textAlign: 'center',
fontSize: 13, color: 'var(--text-muted)',
}}>
Déjà inscrit ?{' '}
<Link to="/login" style={{
color: 'var(--text)', fontWeight: 500, textDecoration: 'underline',
}}>
Se connecter
</Link>
</p>
</>)} {/* fin verifyEmail ternaire */}
</div>
</div>
<style>{`
@media (min-width: 768px) {
.login-bg-col { display: block !important; }
}
`}</style>
</div>
);
}
+254
View File
@@ -0,0 +1,254 @@
import { useState, useEffect } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
export default function ResetPassword() {
const [params] = useSearchParams();
const navigate = useNavigate();
const token = params.get('token') || '';
const [password, setPassword] = useState('');
const [password2, setPassword2] = useState('');
const [status, setStatus] = useState(null); // null | 'done' | 'error'
const [errMsg, setErrMsg] = useState('');
const [busy, setBusy] = useState(false);
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null });
useEffect(() => {
fetch('/api/app-info')
.then(r => r.json())
.then(d => setAppInfo(d))
.catch(() => {});
}, []);
const submit = async (e) => {
e.preventDefault();
if (password !== password2) { setErrMsg('Les mots de passe ne correspondent pas.'); setStatus('error'); return; }
setErrMsg(''); setBusy(true);
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
const data = await res.json();
if (!res.ok) { setErrMsg(data.error || 'Une erreur est survenue.'); setStatus('error'); }
else { setStatus('done'); setTimeout(() => navigate('/login'), 3000); }
} catch {
setErrMsg('Impossible de joindre le serveur.'); setStatus('error');
} finally { setBusy(false); }
};
return (
<div style={{ display: 'flex', minHeight: '100dvh' }}>
{/* ── Colonne gauche — image ───────────────────────────── */}
<div className="login-bg-col" style={{
flex: '1 1 50%',
display: 'none',
position: 'relative',
overflow: 'hidden',
background: '#0d0d0d',
}}>
<img
src="/login-bg.jpg"
alt=""
aria-hidden="true"
onError={e => { e.target.style.display = 'none'; }}
style={{
position: 'absolute', inset: 0,
width: '100%', height: '100%',
objectFit: 'cover', opacity: 0.85,
}}
/>
<div style={{
position: 'absolute', bottom: 40, left: 40,
color: '#fff',
display: 'flex', alignItems: 'center', gap: 12,
}}>
{appInfo.iconUrl && (
<img src={appInfo.iconUrl} alt="" width={36} height={36}
style={{ borderRadius: 8, flexShrink: 0 }} />
)}
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px' }}>
{appInfo.appName}
</span>
</div>
</div>
{/* ── Colonne droite — formulaire ──────────────────────── */}
<div style={{
flex: '1 1 50%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 24px',
background: 'var(--background, #fff)',
minWidth: 0,
}}>
{/* Logo + nom */}
<div 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>
<div style={{ width: '100%', maxWidth: 360 }}>
{!token ? (
/* ── Lien invalide ── */
<div style={{ textAlign: 'center' }}>
<p style={{ color: 'var(--danger)', marginBottom: 20 }}>
Lien de réinitialisation invalide ou manquant.
</p>
<Link to="/forgot-password" style={{
color: 'var(--text)', fontWeight: 500, textDecoration: 'underline',
}}>
Faire une nouvelle demande
</Link>
</div>
) : status === 'done' ? (
/* ── Succès ── */
<div style={{ textAlign: 'center' }}>
<div style={{
width: 52, height: 52, borderRadius: '50%',
background: 'var(--success-bg, #f0fdf4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 20px',
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17l-5-5" stroke="var(--success, #16a34a)" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h1 style={{
margin: '0 0 10px', fontSize: 22, fontWeight: 700,
letterSpacing: '-0.4px', color: 'var(--text)',
}}>Mot de passe mis à jour</h1>
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14 }}>
Redirection vers la connexion dans quelques secondes
</p>
<Link to="/login" style={{
display: 'block', width: '100%', padding: '11px 0',
background: 'var(--primary, #1e40af)', color: '#fff',
borderRadius: 8, fontSize: 15, fontWeight: 600,
textDecoration: 'none', textAlign: 'center',
}}>
Se connecter
</Link>
</div>
) : (
/* ── Formulaire ── */
<>
<div style={{ marginBottom: 28 }}>
<h1 style={{
margin: '0 0 6px', fontSize: 26, fontWeight: 700,
letterSpacing: '-0.5px', color: 'var(--text)',
}}>
Nouveau mot de passe
</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>
Choisissez un mot de passe d'au moins 8 caractères.
</p>
</div>
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{status === 'error' && (
<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)',
}}>
{errMsg}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
Nouveau mot de passe
</label>
<input
className="form-input"
type="password" required minLength={8}
autoComplete="new-password"
placeholder="••••••••"
value={password}
onChange={e => { setPassword(e.target.value); setStatus(null); }}
style={{ width: '100%' }}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>
Confirmer le mot de passe
</label>
<input
className="form-input"
type="password" required minLength={8}
autoComplete="new-password"
placeholder="••••••••"
value={password2}
onChange={e => { setPassword2(e.target.value); setStatus(null); }}
style={{ width: '100%' }}
/>
</div>
<button
type="submit"
disabled={busy}
style={{
marginTop: 4, width: '100%',
padding: '11px 0',
background: busy ? 'var(--text-muted)' : 'var(--primary, #1e40af)',
color: '#fff',
border: 'none', borderRadius: 8,
fontSize: 15, fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
transition: 'background 0.15s',
}}
>
{busy ? 'Enregistrement' : 'Réinitialiser le mot de passe'}
</button>
</form>
<p style={{
marginTop: 24, textAlign: 'center',
fontSize: 13, color: 'var(--text-muted)',
}}>
<Link to="/login" style={{
color: 'var(--text)', fontWeight: 500, textDecoration: 'underline',
}}>
Retour à la connexion
</Link>
</p>
</>
)}
</div>
</div>
<style>{`
@media (min-width: 768px) {
.login-bg-col { display: block !important; }
}
`}</style>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { useState, useEffect } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
export default function VerifyEmail() {
const [params] = useSearchParams();
const token = params.get('token') || '';
const [status, setStatus] = useState('loading'); // 'loading' | 'ok' | 'error'
const [errMsg, setErrMsg] = useState('');
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null });
useEffect(() => {
fetch('/api/app-info').then(r => r.json()).then(d => setAppInfo(d)).catch(() => {});
}, []);
useEffect(() => {
if (!token) { setStatus('error'); setErrMsg('Lien de vérification invalide ou manquant.'); return; }
fetch(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
.then(async r => {
const data = await r.json();
if (!r.ok) { setErrMsg(data.error || 'Lien invalide.'); setStatus('error'); }
else setStatus('ok');
})
.catch(() => { setErrMsg('Impossible de joindre le serveur.'); setStatus('error'); });
}, [token]);
return (
<div style={{ display: 'flex', minHeight: '100dvh' }}>
<div className="login-bg-col" style={{
flex: '1 1 50%', display: 'none', position: 'relative',
overflow: 'hidden', background: '#0d0d0d',
}}>
<img src="/login-bg.jpg" alt="" aria-hidden="true"
onError={e => { e.target.style.display = 'none'; }}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }}
/>
<div style={{ position: 'absolute', bottom: 40, left: 40, color: '#fff', display: 'flex', alignItems: 'center', gap: 12 }}>
{appInfo.iconUrl && <img src={appInfo.iconUrl} alt="" width={36} height={36} style={{ borderRadius: 8 }} />}
<span style={{ fontSize: 18, fontWeight: 600 }}>{appInfo.appName}</span>
</div>
</div>
<div style={{
flex: '1 1 50%', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
padding: '48px 24px', background: 'var(--background, #fff)', minWidth: 0,
}}>
<div 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>
<div style={{ width: '100%', maxWidth: 360, textAlign: 'center' }}>
{status === 'loading' && (
<p style={{ color: 'var(--text-muted)' }}>Vérification en cours</p>
)}
{status === 'ok' && (
<>
<div style={{
width: 56, height: 56, borderRadius: '50%',
background: 'var(--success-bg, #f0fdf4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 20px',
}}>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
<path d="M20 6L9 17l-5-5" stroke="var(--success, #16a34a)" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h1 style={{ margin: '0 0 10px', fontSize: 24, fontWeight: 700, color: 'var(--text)' }}>
Email vérifié !
</h1>
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
Votre adresse email a bien été confirmée. Vous pouvez maintenant vous connecter.
</p>
<Link to="/login" style={{
display: 'block', width: '100%', padding: '11px 0',
background: 'var(--primary, #1e40af)', color: '#fff',
borderRadius: 8, fontSize: 15, fontWeight: 600,
textDecoration: 'none',
}}>
Se connecter
</Link>
</>
)}
{status === 'error' && (
<>
<div style={{
width: 56, height: 56, borderRadius: '50%',
background: 'var(--danger-bg, #fef2f2)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 20px',
}}>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
<path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
stroke="var(--danger, #dc2626)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h1 style={{ margin: '0 0 10px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>
Lien invalide
</h1>
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
{errMsg}
</p>
<Link to="/login" style={{
display: 'block', width: '100%', padding: '11px 0',
background: 'var(--primary, #1e40af)', color: '#fff',
borderRadius: 8, fontSize: 15, fontWeight: 600,
textDecoration: 'none',
}}>
Retour à la connexion
</Link>
</>
)}
</div>
</div>
<style>{`
@media (min-width: 768px) { .login-bg-col { display: block !important; } }
`}</style>
</div>
);
}
+2 -2
View File
@@ -77,7 +77,7 @@ const DEFAULT_STATE = {
username: '',
password: '',
allowUnauth: false,
appName: 'Crowdlending',
appName: 'Crowdlending Tracker',
appUrl: '',
};
@@ -109,7 +109,7 @@ export default function SmtpSection() {
username: data.username || '',
password: '',
allowUnauth: !!data.allowUnauth,
appName: data.appName || 'Crowdlending',
appName: data.appName || 'Crowdlending Tracker',
appUrl: data.appUrl || '',
});
setHasPassword(!!data.hasPassword);
+28 -1
View File
@@ -36,6 +36,21 @@ export default function UsersSection({ currentUserId }) {
});
};
const verifyEmail = (u) => {
setConfirmAction({
title: 'Vérifier l\'email manuellement',
message: `Marquer l'email de ${u.display_name || u.email} comme vérifié ?`,
confirmLabel: 'Confirmer',
onConfirm: async () => {
try {
await api.patch(`/admin/users/${u.id}/verify-email`, {});
load();
} catch (e) { setErr('Erreur : ' + e.message); }
finally { setConfirmAction(null); }
},
});
};
const deleteUser = (u) => {
setConfirmAction({
title: 'Supprimer l\'utilisateur',
@@ -66,6 +81,7 @@ export default function UsersSection({ currentUserId }) {
<th style={{ width: 36 }}>ID</th>
<th>Nom</th>
<th>Email</th>
<th>Email vérifié</th>
<th>Rôle</th>
<th>Créé le</th>
<th>Actions</th>
@@ -77,10 +93,16 @@ export default function UsersSection({ currentUserId }) {
<td style={{ color: 'var(--text-muted)' }}>{u.id}</td>
<td style={{ fontWeight: 500 }}>{u.display_name || <em style={{ color: 'var(--text-muted)' }}></em>}</td>
<td>{u.email}</td>
<td>
{u.email_verified
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--success-bg, #f0fdf4)', color: 'var(--success, #16a34a)', border: '1px solid #bbf7d0' }}> Vérifié</span>
: <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><Badge role={u.role} /></td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
<td>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
className="btn btn-sm btn-outline"
onClick={() => toggleRole(u)}
@@ -89,6 +111,11 @@ export default function UsersSection({ currentUserId }) {
>
{u.role === 'admin' ? '→ Utilisateur' : '→ Admin'}
</button>
{!u.email_verified && (
<button className="btn btn-sm btn-outline" onClick={() => verifyEmail(u)}>
Vérifier email
</button>
)}
{u.id !== currentUserId && (
<button className="btn btn-sm btn-danger" onClick={() => deleteUser(u)}>
Supprimer