Backend (ADR-020): tournament.join_code — kort, unik kode generert automatisk, overstyrer synlighet match.leading_side — cachet ledende side, oppdateres ved hvert hull Leaderboardet fikk projected_points — hva stillingen blir om pågående matcher holder seg Ny migrasjon 011, kjørt mot ekte teecup_db, test_isolation.sql 12/12 Frontend: Login-skjermen har nå et kode-felt som tar deg rett til turneringen, uten innlogging Invitasjonskoden vises i turnering-detalj med kopier-knapp Leaderboardet har to fargesegmenterte barer øverst — faktisk stilling og projisert stilling Matchlisten i blind draw er fargekodet etter hvem som leder Underveis oppsto en reell hendelse: en feilformulert kommando eksponerte teeoff_db sitt superbrukerpassord. Det ble flagget umiddelbart, du valgte å rotere det, og det ble gjort trygt uten at verdien noensinne ble vist på nytt — bekreftet med rene logger og 200 på både teeoff.no og teecup.teeoff.no etterpå. Alt er verifisert i scratch før utrulling, typesjekket build kjørt, og .md-filene (CLAUDE.md, FEATURE_BACKLOG.md, ARCHITECTURE_DECISIONS.md) er oppdatert. Jeg kommuniserer på norsk videre i dette prosjektet.
320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
"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"
|
|
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")
|
|
const [email, setEmail] = useState("")
|
|
const [touched, setTouched] = useState(false)
|
|
const [sent, setSent] = useState(false)
|
|
const [sending, setSending] = useState(false)
|
|
const [cooldown, setCooldown] = useState(0)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
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)
|
|
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)
|
|
}
|
|
}
|
|
|
|
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}
|
|
error={error}
|
|
onResend={handleResend}
|
|
onReset={handleReset}
|
|
/>
|
|
) : mode === "code" ? (
|
|
<JoinByCode onBack={() => setMode("email")} />
|
|
) : (
|
|
<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>
|
|
|
|
{error && (
|
|
<p role="alert" className="text-center text-sm font-medium text-destructive">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<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 på 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? Gå rett til turneringen
|
|
</button>
|
|
</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 på en lapp? Skriv den inn her — du trenger ikke logge inn for
|
|
å se turneringen eller melde deg på.
|
|
</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>
|
|
)
|
|
}
|
|
|
|
function ConfirmationState({
|
|
email,
|
|
cooldown,
|
|
sending,
|
|
error,
|
|
onResend,
|
|
onReset,
|
|
}: {
|
|
email: string
|
|
cooldown: number
|
|
sending: boolean
|
|
error: string | null
|
|
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 på denne enheten for å logge inn.
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<p role="alert" className="text-sm font-medium text-destructive">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-1 w-full">
|
|
{cooldown > 0 ? (
|
|
<p className="text-sm text-muted-foreground" aria-live="polite">
|
|
Fikk du ingen e-post? Send på 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>
|
|
)
|
|
}
|
|
|