SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
8.5 KB · 136 lines tsx
Raw Blame History
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