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.7 KB · 134 lines tsx
Raw Blame History
1import { useEffect, useState, type FormEvent } from 'react';2import { Link, useNavigate, useSearchParams } from 'react-router-dom';3import { useQuery } from '@tanstack/react-query';4import { ArrowLeft, Lock, Mail, MailCheck, ShieldCheck } from 'lucide-react';5import { api, 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';1112export interface AuthConfig { mail: boolean; password: boolean; min_password: number; domains: string[]; courses: string[]; term: string }13interface LoginResp { mode: string; token?: string; user?: User }14interface ForgotResp { sent: boolean; first_time: boolean; ttl_minutes: number; dev_link?: string; hint?: string }1516export function LoginPage() {17  const nav = useNavigate();18  const [params] = useSearchParams();19  const setUser = useAuth((s) => s.setUser);20  const { data: cfg } = useQuery({ queryKey: ['auth-config'], queryFn: () => api<AuthConfig>('/auth/config') });21  const [email, setEmail] = useState(params.get('email') || '');22  const [password, setPassword] = useState('');23  const [mode, setMode] = useState<'password' | 'forgot'>(params.get('mode') === 'forgot' ? 'forgot' : 'password');24  const [busy, setBusy] = useState(false);25  const [error, setError] = useState<string | null>(null);26  const [sent, setSent] = useState<ForgotResp | null>(null);27  const [consent, setConsent] = useState(false);28  const token = params.get('token');2930  // Legacy direct sign-in links (/connexion?token=…) sent before the password flow.31  useEffect(() => {32    if (!token) return;33    setBusy(true);34    api<LoginResp>('/auth/verify', { method: 'POST', body: JSON.stringify({ token }) })35      .then((r) => { if (r.token) setToken(r.token); if (r.user) setUser(r.user); nav('/', { replace: true }); })36      .catch((e) => setError(e.message))37      .finally(() => setBusy(false));38  }, [token, nav, setUser]);3940  const submit = async (e: FormEvent) => {41    e.preventDefault();42    setError(null);43    setSent(null);44    setBusy(true);45    try {46      if (mode === 'forgot') {47        const r = await api<ForgotResp>('/auth/forgot', { method: 'POST', body: JSON.stringify({ email: email.trim() }) });48        setSent(r);49        return;50      }51      if (!consent) { setError('Merci d\'accepter la politique de confidentialité pour continuer.'); return; }52      const r = await api<LoginResp>('/auth/login', { method: 'POST', body: JSON.stringify({ email: email.trim(), password }) });53      if (r.token) setToken(r.token);54      if (r.user) setUser(r.user);55      await api('/me/consent', { method: 'POST' }).catch(() => undefined);56      nav('/', { replace: true });57    } catch (e) {58      setError(e instanceof Error ? e.message : 'Connexion impossible.');59    } finally {60      setBusy(false);61    }62  };6364  return (65    <div className="min-h-[100dvh] flex flex-col bg-neutral-surface">66      <div className="relative overflow-hidden bg-uqo-gradient text-white px-6 pt-[calc(28px+var(--safe-top))] pb-16">67        <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>68        <div className="mx-auto max-w-[440px]">69          <Logo inverted size="lg" />70          <p className="mt-5 text-[15px] text-white/85 max-w-[380px]">Comprendre, pratiquer et calculer en évaluation immobilière — avec un tuteur qui cite tes notes de cours.</p>71        </div>72      </div>73      <div className="flex-1 px-4 -mt-9 pb-[calc(24px+var(--safe-bottom))]">74        <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">75          {mode === 'password' ? (76            <div>77              <h1 className="text-xl font-bold text-uqo-blue-dark">Connexion</h1>78              <p className="text-sm text-neutral-muted mt-1">IMM1003 · IMM1033 · {cfg?.term || ''}. Réservé aux étudiant·es inscrit·es par le professeur (courriel <b>@{cfg?.domains?.[0] || 'uqo.ca'}</b>).</p>79            </div>80          ) : (81            <div>82              <button type="button" onClick={() => { setMode('password'); setSent(null); setError(null); }} className="inline-flex items-center gap-1 text-sm text-uqo-blue min-h-[32px]"><ArrowLeft size={16} /> Retour</button>83              <h1 className="text-xl font-bold text-uqo-blue-dark mt-1">Première connexion ou mot de passe oublié</h1>84              <p className="text-sm text-neutral-muted mt-1">Saisis ton courriel UQO : tu recevras un lien pour choisir ton mot de passe. Le lien est valide {sent?.ttl_minutes ?? 60} minutes.</p>85            </div>86          )}87          {token && busy && <div className="flex items-center gap-2 text-sm"><Spinner /> Vérification du lien…</div>}88          {sent ? (89            <div className="rounded-2xl bg-uqo-blue-light p-4 text-sm text-uqo-blue-dark space-y-2">90              <div className="flex items-center gap-2 font-semibold"><MailCheck size={18} /> {sent.sent ? 'Courriel envoyé' : 'Courriel non envoyé'}</div>91              {sent.sent && <p>Ouvre le message « {sent.first_time ? 'Choisis ton mot de passe UQO-Chat' : 'Réinitialisation de ton mot de passe UQO-Chat'} » envoyé à <b>{email.trim()}</b> et clique sur le bouton. Pense à vérifier les indésirables.</p>}92              {sent.hint && <p>{sent.hint}</p>}93              {sent.dev_link && <p className="break-all">Mode développement — lien : <a className="underline" href={sent.dev_link}>{sent.dev_link}</a></p>}94              <button type="button" onClick={() => { setMode('password'); setSent(null); }} className="text-uqo-blue underline min-h-[32px]">Retour à la connexion</button>95            </div>96          ) : (97            <>98              <label className="block">99                <span className="text-sm font-medium">Courriel UQO</span>100                <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue">101                  <Mail size={18} className="ml-3 text-neutral-muted" />102                  <input type="email" required autoComplete="username" inputMode="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="prenom.nom@uqo.ca" className="h-12 flex-1 bg-transparent px-3 outline-none" />103                </span>104              </label>105              {mode === 'password' && (106                <div>107                  <label className="block">108                    <span className="text-sm font-medium">Mot de passe</span>109                    <span className="mt-1 flex items-center rounded-xl border border-neutral-line focus-within:border-uqo-blue">110                      <Lock size={18} className="ml-3 text-neutral-muted" />111                      <input type="password" required value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" className="h-12 flex-1 bg-transparent px-3 outline-none" />112                    </span>113                  </label>114                  <button type="button" onClick={() => { setMode('forgot'); setError(null); }} className="mt-1.5 text-sm text-uqo-blue underline min-h-[32px] text-left">Première connexion ou mot de passe oublié ?</button>115                </div>116              )}117              {mode === 'password' && (118                <label className="flex items-start gap-2 text-sm">119                  <input type="checkbox" checked={consent} onChange={(e) => setConsent(e.target.checked)} className="mt-1 h-4 w-4 accent-uqo-blue" />120                  <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>121                </label>122              )}123              {error && <p className="text-sm text-semantic-error">{error}</p>}124              <Button type="submit" size="lg" className="w-full" disabled={busy}>{busy ? <Spinner className="text-white" /> : mode === 'forgot' ? 'Recevoir le lien par courriel' : 'Se connecter'}</Button>125            </>126          )}127          <p className="text-xs text-neutral-muted flex items-start gap-1.5"><ShieldCheck size={14} className="shrink-0 mt-0.5" /> Outil pédagogique. Ne remplace pas un évaluateur agréé (É.A.) de l'OEAQ. L'utilisation de l'IA doit être déclarée dans tes travaux, conformément au plan de cours.</p>128          <p className="text-xs text-neutral-muted">Pas encore de compte ? Ton professeur doit ajouter ton courriel dans UQO-Chat ; tu recevras alors une invitation. <Link to="/confidentialite" className="underline">Confidentialité</Link></p>129        </form>130      </div>131    </div>132  );133}134