SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
3 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%
5.9 KB · 56 lines tsx
Raw Blame History
1"use client";2import { useEffect, useRef, useState } from "react";3import Link from "next/link";4import { useRouter, useSearchParams } from "next/navigation";5import { Logo } from "@/components/logo";6import { Notice } from "@/components/ui";78export function LoginView() {9  const sp = useSearchParams(); const router = useRouter(); const next = sp.get("next") && sp.get("next")!.startsWith("/") ? sp.get("next")! : "/membre";10  const [step, setStep] = useState<"email" | "code">("email");11  const [email, setEmail] = useState(""); const [accept, setAccept] = useState(false); const [challengeId, setChallengeId] = useState<string | null>(null);12  const [code, setCode] = useState(""); const [err, setErr] = useState<string | null>(null); const [busy, setBusy] = useState(false); const [cooldown, setCooldown] = useState(0);13  const [needTerms, setNeedTerms] = useState(false);14  const codeRef = useRef<HTMLInputElement>(null);15  useEffect(() => { if (cooldown <= 0) return; const t = setTimeout(() => setCooldown((c) => c - 1), 1000); return () => clearTimeout(t); }, [cooldown]);16  const request = async () => {17    setBusy(true); setErr(null);18    const r = await fetch("/api/auth/request-code", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email }) }); const d = await r.json(); setBusy(false);19    if (r.status === 400) { setErr(d.error?.message ?? "Adresse invalide"); return; }20    if (r.status === 429) { setErr(d.message); setCooldown(d.retryAfterSec ?? 60); if (challengeId) setStep("code"); return; }21    setChallengeId(d.challengeId); setCooldown(d.retryAfterSec ?? 60); setStep("code"); setTimeout(() => codeRef.current?.focus(), 50);22  };23  const verify = async (c = code) => {24    if (!challengeId || c.length !== 6) return;25    setBusy(true); setErr(null);26    const r = await fetch("/api/auth/verify-code", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ challengeId, code: c, acceptTerms: accept }) }); const d = await r.json(); setBusy(false);27    if (r.status === 409) { setNeedTerms(true); setErr(d.error?.message ?? "Acceptez les conditions puis confirmez le code."); return; }28    if (!r.ok) { setErr(d.error?.message ?? "Erreur"); if (["expired", "too_many_attempts", "not_found"].includes(d.error?.code)) { setStep("email"); setChallengeId(null); setCode(""); } return; }29    router.push(next); router.refresh();30  };31  return (32    <div className="mx-auto max-w-md px-4 py-10 md:py-16">33      <div className="text-center"><Logo /><h1 className="mt-4 text-2xl font-extrabold tracking-tight">{step === "email" ? "Connexion ou création de compte" : "Entrez votre code"}</h1><p className="mt-1 text-sm text-ink-2">{step === "email" ? "Une seule identité pour chercher, publier, échanger. Sans mot de passe : un code à usage unique vous est envoyé par courriel." : <>Un code à 6 chiffres a été envoyé à <strong>{email}</strong> s'il s'agit d'une adresse valide. Valable 10 minutes.</>}</p></div>34      <div className="tv-card p-5 mt-6">35        {step === "email" ? (36          <form onSubmit={(e) => { e.preventDefault(); request(); }} className="space-y-4">37            <label className="block"><span className="tv-label">Adresse courriel</span><input type="email" autoComplete="email" inputMode="email" required value={email} onChange={(e) => setEmail(e.target.value)} className="tv-input !pl-4 mt-1" placeholder="vous@exemple.ca" /></label>38            <label className="flex gap-2 text-xs text-ink-2 items-start"><input type="checkbox" checked={accept} onChange={(e) => setAccept(e.target.checked)} className="mt-0.5 accent-[#e0263c]" /><span>Si je crée un compte, j'accepte les <Link href="/conditions" className="underline" target="_blank">conditions d'utilisation</Link> et la <Link href="/confidentialite" className="underline" target="_blank">politique de confidentialité</Link>. (Facultatif si vous avez déjà un compte.)</span></label>39            {err && <Notice tone="warn">{err}</Notice>}40            <button type="submit" disabled={busy || cooldown > 0 && !!challengeId} className="tv-btn tv-btn-primary w-full">{busy ? "Envoi…" : "Recevoir mon code"}</button>41          </form>42        ) : (43          <form onSubmit={(e) => { e.preventDefault(); verify(); }} className="space-y-4">44            <label className="block"><span className="tv-label">Code à 6 chiffres</span><input ref={codeRef} type="text" inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]{6}" maxLength={6} value={code} onChange={(e) => { const v = e.target.value.replace(/\D/g, "").slice(0, 6); setCode(v); if (v.length === 6) verify(v); }} className="tv-input !pl-4 mt-1 text-center text-2xl tracking-[.4em] font-bold" placeholder="••••••" /></label>45            {needTerms && <label className="flex gap-2 text-xs text-ink-2 items-start"><input type="checkbox" checked={accept} onChange={(e) => setAccept(e.target.checked)} className="mt-0.5 accent-[#e0263c]" /><span>J'accepte les <Link href="/conditions" className="underline" target="_blank">conditions</Link> et la <Link href="/confidentialite" className="underline" target="_blank">politique de confidentialité</Link>.</span></label>}46            {err && <Notice tone="warn">{err}</Notice>}47            <button type="submit" disabled={busy || code.length !== 6} className="tv-btn tv-btn-primary w-full">{busy ? "Vérification…" : "Me connecter"}</button>48            <div className="flex justify-between text-xs text-ink-3"><button type="button" onClick={() => { setStep("email"); setCode(""); setErr(null); }} className="underline">Changer d'adresse</button><button type="button" disabled={cooldown > 0 || busy} onClick={request} className="underline disabled:no-underline disabled:opacity-60">{cooldown > 0 ? `Renvoyer dans ${cooldown} s` : "Renvoyer un code"}</button></div>49          </form>50        )}51      </div>52      <p className="mt-4 text-xs text-ink-3 text-center">Si vous n'avez rien demandé, ignorez le courriel : personne ne peut se connecter sans le code.</p>53    </div>54  );55}56