"use client" import type React from "react" import { useEffect, useId, useState } from "react" import { useRouter } from "next/navigation" import { cn } from "@/lib/utils" import { TwoFactorSetupForm, TwoFactorVerifyForm } from "@/components/two-factor-flow" /* ------------------------------------------------------------------ */ /* Icons — inline SVG (currentColor), so every action can pair an */ /* icon with a real text label and we control size/stroke precisely. */ /* ------------------------------------------------------------------ */ type IconProps = { className?: string } const stroke = { fill: "none", stroke: "currentColor", strokeWidth: 1.75, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, } function MailIcon({ className }: IconProps) { return ( ) } function KeyIcon({ className }: IconProps) { return ( ) } function TicketIcon({ className }: IconProps) { return ( ) } function EyeIcon({ className }: IconProps) { return ( ) } function EyeOffIcon({ className }: IconProps) { return ( ) } function ArrowLeftIcon({ className }: IconProps) { return ( ) } function AlertIcon({ className }: IconProps) { return ( ) } function CheckIcon({ className }: IconProps) { return ( ) } function FlagIcon({ className }: IconProps) { return ( ) } function Spinner({ className }: IconProps) { return ( ) } /* ------------------------------------------------------------------ */ /* Shared styles */ /* ------------------------------------------------------------------ */ const focusRing = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tee-strong focus-visible:ring-offset-2 focus-visible:ring-offset-clubhouse-card" const inputBase = "w-full h-12 rounded-xl border bg-clubhouse-field px-4 text-base text-clubhouse-ink placeholder:text-clubhouse-muted/70 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tee-strong/70" const primaryBtn = cn( "inline-flex w-full min-h-[3.25rem] items-center justify-center gap-2 rounded-xl bg-tee-strong px-5 text-base font-semibold text-white shadow-sm transition-colors hover:bg-[#265a18] active:translate-y-px disabled:cursor-not-allowed disabled:opacity-55", focusRing, ) const secondaryBtn = cn( "inline-flex w-full min-h-[2.75rem] items-center justify-center gap-2 rounded-xl border border-clubhouse-border bg-clubhouse-card px-4 py-2.5 text-base font-medium text-clubhouse-ink transition-colors hover:border-tee-strong/40 hover:bg-clubhouse-field", focusRing, ) const backBtn = cn( "-mx-2 inline-flex min-h-[2.75rem] items-center gap-1.5 rounded-lg px-2 text-base font-medium text-tee-strong transition-colors hover:underline", focusRing, ) const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const RESEND_SECONDS = 30 type Mode = "email" | "sent" | "password" | "code" type PostAuthMode = "2fa-verify" | "2fa-setup" | null type SessionUser = { id: string email: string display_name: string preferred_locale: string } type LoginResult = { status: "success" | "2fa_required" | "2fa_setup_required" user?: SessionUser two_factor_method?: "totp" | "email" } /* ------------------------------------------------------------------ */ /* Small building blocks */ /* ------------------------------------------------------------------ */ function FieldError({ id, children }: { id: string; children: React.ReactNode }) { return ( ) } function FormAlert({ children }: { children: React.ReactNode }) { return (
{children}
) } /* ------------------------------------------------------------------ */ /* Main component */ /* ------------------------------------------------------------------ */ export function TeeCupAuth() { const router = useRouter() const [mode, setMode] = useState("email") const [postAuth, setPostAuth] = useState(null) const [twoFactorMethod, setTwoFactorMethod] = useState<"totp" | "email">("totp") // shared field state (email persists across email <-> password) const [email, setEmail] = useState("") const [emailTouched, setEmailTouched] = useState(false) const emailInvalid = emailTouched && email.length > 0 && !EMAIL_RE.test(email) const emailIds = useId() const emailErrId = `${emailIds}-email-err` /* form-level (server) error, shared across states */ const [formError, setFormError] = useState(null) const [loading, setLoading] = useState(false) function handleLoginResult(result: LoginResult) { if (result.status === "success") { router.replace("/dashboard") } else if (result.status === "2fa_required") { setTwoFactorMethod(result.two_factor_method ?? "totp") setPostAuth("2fa-verify") } else { setPostAuth("2fa-setup") } } function handleTwoFactorSuccess() { router.replace("/dashboard") } /* ---- State 1: magic link, ekte /auth/request-link ---- */ async function handleSendLink(e: React.FormEvent) { e.preventDefault() setEmailTouched(true) if (!EMAIL_RE.test(email) || loading) return setFormError(null) setLoading(true) 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}`) setMode("sent") setCooldown(RESEND_SECONDS) } catch { setFormError("Klarte ikke å sende lenken. Sjekk tilkoblingen og prøv igjen.") } finally { setLoading(false) } } /* ---- State 2: cooldown ---- */ const [cooldown, setCooldown] = useState(0) const [resentNote, setResentNote] = useState(false) useEffect(() => { if (cooldown <= 0) return const t = setInterval(() => setCooldown((c) => (c <= 1 ? 0 : c - 1)), 1000) return () => clearInterval(t) }, [cooldown]) async function handleResend() { if (cooldown > 0 || loading) return setResentNote(false) setFormError(null) setLoading(true) try { 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}`) setCooldown(RESEND_SECONDS) setResentNote(true) } catch { setFormError("Klarte ikke å sende lenken. Sjekk tilkoblingen og prøv igjen.") } finally { setLoading(false) } } /* ---- State 3: password, ekte /auth/login-password ---- */ const [password, setPassword] = useState("") const [showPassword, setShowPassword] = useState(false) const pwIds = useId() async function handlePasswordLogin(e: React.FormEvent) { e.preventDefault() if (!email.trim() || !password || loading) return setFormError(null) setLoading(true) try { const res = await fetch("/auth/login-password", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ email: email.trim(), password }), }) if (!res.ok) { const body = await res.json().catch(() => null) throw new Error(body?.detail?.message ?? "E-post eller passord er feil.") } handleLoginResult(await res.json()) } catch (err) { setFormError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.") } finally { setLoading(false) } } /* ---- State 4: join by code, ekte /public/tournaments/by-code ---- */ const [code, setCode] = useState("") async function handleJoin(e: React.FormEvent) { e.preventDefault() const trimmed = code.trim() if (!trimmed || loading) return setFormError(null) setLoading(true) try { const res = await fetch(`/public/tournaments/by-code/${encodeURIComponent(trimmed)}`) if (!res.ok) { const body = await res.json().catch(() => null) throw new Error(body?.detail?.message ?? "Fant ingen turnering med denne koden.") } const data: { tournament_id: string } = await res.json() router.push(`/t/${data.tournament_id}?code=${encodeURIComponent(trimmed)}`) } catch (err) { setFormError(err instanceof Error ? err.message : "Klarte ikke å slå opp koden. Sjekk tilkoblingen og prøv igjen.") } finally { setLoading(false) } } // Reset transient per-mode state when switching modes. function switchMode(next: Mode, opts?: { clearEmail?: boolean }) { setFormError(null) setPassword("") setShowPassword(false) setResentNote(false) if (opts?.clearEmail) { setEmail("") setEmailTouched(false) } if (next === "code") setCode("") setMode(next) } /* ---------------- 2FA continuation (shared with password login) --- */ if (postAuth === "2fa-verify") { return ( setPostAuth(null)} /> ) } if (postAuth === "2fa-setup") { return } return (
{/* ---------- State 1: Magic link (primary path) ---------- */} {mode === "email" && (

Logg inn

Klart for en runde? Skriv inn e-posten din, så sender vi deg en lenke.

setEmail(e.target.value)} onBlur={() => setEmailTouched(true)} aria-invalid={emailInvalid || undefined} aria-describedby={emailInvalid ? emailErrId : undefined} className={cn(inputBase, emailInvalid ? "border-cup-strong ring-2 ring-cup-strong/30" : "border-clubhouse-border")} /> {emailInvalid && Skriv inn en gyldig e-postadresse, f.eks. navn@klubb.no.}
{formError &&
{{formError}}
}

Ingen passord nødvendig. Vi sender deg en sikker lenke på e-post.

)} {/* ---------- State 2: Link sent ---------- */} {mode === "sent" && (

Lenke sendt

Vi sendte en innloggingslenke til {email}.

Åpne lenken på denne enheten for å logge inn. Det kan ta et minutt før den kommer frem.

{formError &&
{{formError}}
} {resentNote && !formError && (

Lenken er sendt på nytt.

)}
)} {/* ---------- State 3: Password fallback ---------- */} {mode === "password" && (

Logg inn med passord

For deg som har satt et passord på kontoen din.

{formError &&
{{formError}}
}
setEmail(e.target.value)} className={cn(inputBase, "border-clubhouse-border")} />
setPassword(e.target.value)} className={cn(inputBase, "border-clubhouse-border pr-14")} />
)} {/* ---------- State 4: Join by code ---------- */} {mode === "code" && (

Bli med via kode

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å.

{formError &&
{{formError}}
}
setCode(e.target.value.toUpperCase())} className={cn(inputBase, "border-clubhouse-border font-mono tracking-[0.15em] uppercase")} />
)}
) }