Python 64.6%
TypeScript 33.7%
CSS 0.8%
1import { useState, type FormEvent } from 'react';2import { Link, useNavigate, useSearchParams } from 'react-router-dom';3import { useQuery } from '@tanstack/react-query';4import { CheckCircle2, Eye, EyeOff, Lock, ShieldCheck, XCircle } from 'lucide-react';5import { api, ApiError, setToken } from '@/lib/api';6import type { User } from '@/lib/types';7import { useAuth } from '@/stores/auth';8import { Logo } from '@/components/ui/logo';9import { Button } from '@/components/ui/button';10import { Spinner } from '@/components/ui/spinner';1112interface TokenInfo { email: string; purpose: 'invite' | 'reset'; first_time: boolean; display_name: string | null; expires_at: string }13interface SetResp { token?: string; user?: User }1415const MIN = 8;1617function strength(pw: string): { score: number; label: string } {18 let s = 0;19 if (pw.length >= MIN) s++;20 if (pw.length >= 12) s++;21 if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++;22 if (/\d/.test(pw)) s++;23 if (/[^A-Za-z0-9]/.test(pw)) s++;24 const label = pw.length < MIN ? 'Trop court' : s <= 2 ? 'Faible' : s === 3 ? 'Correct' : s === 4 ? 'Bon' : 'Excellent';25 return { score: pw.length < MIN ? 0 : s, label };26}2728/** /mot-de-passe?token=… — first activation (invitation) or password reset. */29export function SetPasswordPage() {30 const nav = useNavigate();31 const [params] = useSearchParams();32 const token = params.get('token') || '';33 const setUser = useAuth((s) => s.setUser);34 const info = useQuery({35 queryKey: ['password-token', token],36 queryFn: () => api<TokenInfo>(`/auth/password-token?token=${encodeURIComponent(token)}`),37 enabled: !!token,38 retry: false,39 });40 const [pw, setPw] = useState('');41 const [pw2, setPw2] = useState('');42 const [show, setShow] = useState(false);43 const [consent, setConsent] = useState(false);44 const [busy, setBusy] = useState(false);45 const [error, setError] = useState<string | null>(null);46 const st = strength(pw);47 const firstTime = info.data?.first_time ?? true;4849 const submit = async (e: FormEvent) => {50 e.preventDefault();51 setError(null);52 if (pw.length < MIN) { setError(`Le mot de passe doit contenir au moins ${MIN} caractères.`); return; }53 if (pw !== pw2) { setError('Les deux mots de passe ne correspondent pas.'); return; }54 if (firstTime && !consent) { setError('Merci d\'accepter la politique de confidentialité pour continuer.'); return; }55 setBusy(true);56 try {57 const r = await api<SetResp>('/auth/set-password', { method: 'POST', body: JSON.stringify({ token, password: pw }) });58 if (r.token) setToken(r.token);59 if (r.user) setUser(r.user);60 if (firstTime) await api('/me/consent', { method: 'POST' }).catch(() => undefined);61 nav('/', { replace: true });62 } catch (err) {63 setError(err instanceof Error ? err.message : 'Impossible d\'enregistrer le mot de passe.');64 } finally {65 setBusy(false);66 }67 };6869 const invalid = !token || (info.isError && (info.error as ApiError | undefined)?.status === 400) || info.isError;70 const title = info.data ? (firstTime ? 'Choisis ton mot de passe' : 'Nouveau mot de passe') : 'Mot de passe';7172 return (73 <div className="min-h-[100dvh] flex flex-col bg-neutral-surface">74 <div className="relative overflow-hidden bg-uqo-gradient text-white px-6 pt-[calc(28px+var(--safe-top))] pb-16">75 <svg className="absolute -right-16 -top-16 h-72 w-72 opacity-10" viewBox="0 0 200 200" aria-hidden="true"><circle cx="100" cy="100" r="100" fill="white" /></svg>76 <div className="mx-auto max-w-[440px]">77 <Logo inverted size="lg" />78 <p className="mt-5 text-[15px] text-white/85 max-w-[380px]">{firstTime ? 'Bienvenue ! Une dernière étape avant de commencer avec ton tuteur.' : 'Choisis un nouveau mot de passe pour ton compte.'}</p>79 </div>80 </div>81 <div className="flex-1 px-4 -mt-9 pb-[calc(24px+var(--safe-bottom))]">82 <form onSubmit={submit} className="relative z-10 mx-auto max-w-[440px] rounded-[24px] bg-white shadow-float p-6 space-y-4 animate-fadein">83 {info.isLoading && token && <div className="flex items-center gap-2 text-sm"><Spinner /> Vérification du lien…</div>}84 {invalid && !info.isLoading && (85 <div className="space-y-3">86 <div className="flex items-center gap-2 text-semantic-error font-semibold"><XCircle size={20} /> Lien invalide ou expiré</div>87 <p className="text-sm text-neutral-muted">Les liens ne servent qu'une fois et expirent. Demande un nouveau lien : il arrivera par courriel en quelques secondes.</p>88 <Link to="/connexion?mode=forgot" className="block"><Button type="button" size="lg" className="w-full">Demander un nouveau lien</Button></Link>89 <Link to="/connexion" className="block text-center text-sm text-uqo-blue underline min-h-[32px]">Retour à la connexion</Link>90 </div>91 )}92 {info.data && (93 <>94 <div>95 <h1 className="text-xl font-bold text-uqo-blue-dark">{title}</h1>96 <p className="text-sm text-neutral-muted mt-1">Compte <b>{info.data.email}</b>{info.data.display_name && !info.data.display_name.includes('@') ? ` · ${info.data.display_name}` : ''}. Tu te connecteras ensuite avec ce courriel et ce mot de passe.</p>97 </div>98 <label className="block">99 <span className="text-sm font-medium">Mot de passe</span>100 <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue">101 <Lock size={18} className="ml-3 text-neutral-muted" />102 <input type={show ? 'text' : 'password'} required minLength={MIN} value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="new-password" placeholder={`${MIN} caractères minimum`} className="h-12 flex-1 bg-transparent px-3 outline-none" />103 <button type="button" onClick={() => setShow((s) => !s)} className="h-12 w-12 inline-flex items-center justify-center text-neutral-muted" aria-label={show ? 'Masquer' : 'Afficher'}>{show ? <EyeOff size={18} /> : <Eye size={18} />}</button>104 </span>105 {pw && (106 <span className="mt-2 flex items-center gap-2 text-xs text-neutral-muted">107 <span className="flex gap-1 flex-1">{[1, 2, 3, 4, 5].map((i) => <span key={i} className={`h-1.5 flex-1 rounded-full ${i <= st.score ? (st.score <= 2 ? 'bg-semantic-error' : st.score === 3 ? 'bg-amber-400' : 'bg-uqo-green') : 'bg-neutral-line'}`} />)}</span>108 <span className="w-20 text-right">{st.label}</span>109 </span>110 )}111 </label>112 <label className="block">113 <span className="text-sm font-medium">Confirme le mot de passe</span>114 <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue">115 <Lock size={18} className="ml-3 text-neutral-muted" />116 <input type={show ? 'text' : 'password'} required value={pw2} onChange={(e) => setPw2(e.target.value)} autoComplete="new-password" className="h-12 flex-1 bg-transparent px-3 outline-none" />117 {pw2 && (pw === pw2 ? <CheckCircle2 size={18} className="mr-3 text-uqo-green" /> : <XCircle size={18} className="mr-3 text-semantic-error" />)}118 </span>119 </label>120 {firstTime && (121 <label className="flex items-start gap-2 text-sm">122 <input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} className="mt-1 h-4 w-4 accent-uqo-blue" />123 <span>J'ai lu la <a href="/confidentialite" target="_blank" className="text-uqo-blue underline">politique de confidentialité</a> et j'accepte que mes conversations soient traitées pour m'aider dans le cours (Loi 25).</span>124 </label>125 )}126 {error && <p className="text-sm text-semantic-error">{error}</p>}127 <Button type="submit" size="lg" className="w-full" disabled={busy || pw.length < MIN || pw !== pw2}>{busy ? <Spinner className="text-white" /> : firstTime ? 'Activer mon compte' : 'Enregistrer le mot de passe'}</Button>128 <p className="text-xs text-neutral-muted flex items-start gap-1.5"><ShieldCheck size={14} className="shrink-0 mt-0.5" /> Mot de passe chiffré (PBKDF2). Personne, pas même le professeur, ne peut le lire.</p>129 </>130 )}131 </form>132 </div>133 </div>134 );135}136