teecup/frontend/components/login-form.tsx

321 lines
10 KiB
TypeScript
Raw Normal View History

2026-07-17 21:40:42 +02:00
"use client"
import type React from "react"
import { useEffect, useRef, useState } from "react"
import { useRouter } from "next/navigation"
import { Mail, ArrowLeft, CheckCircle2, KeyRound } from "lucide-react"
2026-07-17 21:40:42 +02:00
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
const RESEND_COOLDOWN = 30 // seconds
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
}
export function LoginForm() {
const [mode, setMode] = useState<"email" | "code">("email")
2026-07-17 21:40:42 +02:00
const [email, setEmail] = useState("")
const [touched, setTouched] = useState(false)
const [sent, setSent] = useState(false)
const [sending, setSending] = useState(false)
const [cooldown, setCooldown] = useState(0)
2026-07-17 21:57:44 +02:00
const [error, setError] = useState<string | null>(null)
2026-07-17 21:40:42 +02:00
const emailValid = isValidEmail(email)
const showError = touched && email.length > 0 && !emailValid
// Countdown timer for the resend cooldown.
useEffect(() => {
if (cooldown <= 0) return
const id = setInterval(() => {
setCooldown((c) => (c <= 1 ? 0 : c - 1))
}, 1000)
return () => clearInterval(id)
}, [cooldown])
async function sendLink() {
setSending(true)
2026-07-17 21:57:44 +02:00
setError(null)
try {
// Alltid samme suksess-respons uansett om e-posten finnes (anti-
// enumerering, se ADR-009) -- kun nettverks-/serverfeil havner i catch.
const res = await fetch("/auth/request-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: email.trim(), locale: "nb" }),
})
if (!res.ok) throw new Error(`request-link: ${res.status}`)
setSent(true)
setCooldown(RESEND_COOLDOWN)
} catch {
setError("Klarte ikke å sende lenken. Sjekk tilkoblingen og prøv igjen.")
} finally {
setSending(false)
}
2026-07-17 21:40:42 +02:00
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setTouched(true)
if (!emailValid || sending) return
void sendLink()
}
function handleResend() {
if (cooldown > 0 || sending) return
void sendLink()
}
function handleReset() {
setSent(false)
setTouched(false)
}
return (
<div className="w-full rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
{sent ? (
<ConfirmationState
email={email}
cooldown={cooldown}
sending={sending}
2026-07-17 21:57:44 +02:00
error={error}
2026-07-17 21:40:42 +02:00
onResend={handleResend}
onReset={handleReset}
/>
) : mode === "code" ? (
<JoinByCode onBack={() => setMode("email")} />
2026-07-17 21:40:42 +02:00
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-sm font-semibold">
E-post
</Label>
<div className="relative">
<Mail
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="email"
type="email"
inputMode="email"
autoComplete="email"
autoFocus
placeholder="deg@epost.no"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => setTouched(true)}
aria-invalid={showError}
aria-describedby={showError ? "email-error" : undefined}
className="h-14 rounded-2xl pl-11 text-base"
/>
</div>
{showError && (
<p id="email-error" className="text-sm font-medium text-destructive">
Skriv inn en gyldig e-postadresse.
</p>
)}
</div>
2026-07-17 21:57:44 +02:00
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
{error}
</p>
)}
2026-07-17 21:40:42 +02:00
<Button
type="submit"
disabled={sending}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
{sending ? "Sender …" : "Send innloggingslenke"}
</Button>
<p className="text-center text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen passord. Vi sender deg en sikker lenke e-post.
</p>
<button
type="button"
onClick={() => setMode("code")}
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<KeyRound aria-hidden="true" className="size-4" />
Har du en invitasjonskode? rett til turneringen
</button>
2026-07-17 21:40:42 +02:00
</form>
)}
</div>
)
}
// --- Kode-innlogging (ADR-020): tar deg rett til en turnering via en kort,
// menneske-skrivbar kode -- FØR innlogging, og uansett turneringens
// synlighet (koden ER selve invitasjonen, se ADR-020 Beslutning A). ---------
function JoinByCode({ onBack }: { onBack: () => void }) {
const router = useRouter()
const [code, setCode] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const trimmed = code.trim()
if (!trimmed || loading) return
setLoading(true)
setError(null)
try {
const res = await fetch(`/public/tournaments/by-code/${encodeURIComponent(trimmed)}`)
if (!res.ok) {
const body = await res.json().catch(() => null)
setError(body?.detail?.message ?? "Fant ingen turnering med denne koden.")
return
}
const data: { tournament_id: string } = await res.json()
router.push(`/t/${data.tournament_id}?code=${encodeURIComponent(trimmed)}`)
} catch {
setError("Klarte ikke å slå opp koden. Sjekk tilkoblingen og prøv igjen.")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2">
<Label htmlFor="join-code" className="text-sm font-semibold">
Invitasjonskode
</Label>
<div className="relative">
<KeyRound
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="join-code"
type="text"
autoCapitalize="characters"
autoComplete="off"
autoFocus
placeholder="F.eks. RAPWAR"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className="h-14 rounded-2xl pl-11 text-base tracking-widest"
/>
</div>
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
{error}
</p>
)}
<Button
type="submit"
disabled={loading || !code.trim()}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
{loading ? "Sjekker …" : "Gå til turnering"}
</Button>
<p className="text-center text-sm leading-relaxed text-muted-foreground text-pretty">
Fått en kode muntlig eller en lapp? Skriv den inn her du trenger ikke logge inn for
å se turneringen eller melde deg .
</p>
<button
type="button"
onClick={onBack}
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-4" />
Tilbake til innlogging
</button>
</form>
)
}
2026-07-17 21:40:42 +02:00
function ConfirmationState({
email,
cooldown,
sending,
2026-07-17 21:57:44 +02:00
error,
2026-07-17 21:40:42 +02:00
onResend,
onReset,
}: {
email: string
cooldown: number
sending: boolean
2026-07-17 21:57:44 +02:00
error: string | null
2026-07-17 21:40:42 +02:00
onResend: () => void
onReset: () => void
}) {
const liveRef = useRef<HTMLHeadingElement>(null)
useEffect(() => {
liveRef.current?.focus()
}, [])
return (
<div className="flex flex-col items-center gap-5 text-center">
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<CheckCircle2 aria-hidden="true" className="size-8 text-primary" />
</div>
<div className="flex flex-col gap-2">
<h2
ref={liveRef}
tabIndex={-1}
className="text-2xl font-extrabold tracking-tight outline-none text-balance"
>
Sjekk innboksen din
</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Vi har sendt en innloggingslenke til{" "}
<span className="font-semibold text-foreground">{email}</span>. Åpne den denne enheten for å logge inn.
</p>
</div>
2026-07-17 21:57:44 +02:00
{error && (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
)}
2026-07-17 21:40:42 +02:00
<div className="mt-1 w-full">
{cooldown > 0 ? (
<p className="text-sm text-muted-foreground" aria-live="polite">
Fikk du ingen e-post? Send nytt om{" "}
<span className="font-semibold text-foreground tabular-nums">{cooldown}s</span>
</p>
) : (
<Button
type="button"
variant="ghost"
onClick={onResend}
disabled={sending}
className="h-11 rounded-xl font-semibold text-primary hover:bg-primary/10 hover:text-primary"
>
{sending ? "Sender …" : "Send lenken på nytt"}
</Button>
)}
</div>
<button
type="button"
onClick={onReset}
className="mt-1 inline-flex items-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-4" />
Bruk en annen e-post
</button>
</div>
)
}