Python 64.6%
TypeScript 33.7%
CSS 0.8%
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