teecup/frontend/components/teecup/teecup-auth.tsx
Erol Haagenrud b0c65d983b Gjør /logg-inn til den ekte innloggingssiden
/logg-inn (clubhouse-palett, TeeCupAuth) var en foreldreløs V0-
utforskning -- ingenting i appen lenket eller redirectet dit, og
TeeCupAuth var 100% mock (sleep()-baserte stubber, hardkodet
demo-passord, falske invitasjonskoder, en falsk "2FA-kode sendes"-tekst
som aldri sendte noe). Flagget eksplisitt til bruker før arbeid startet,
siden dette er sikkerhetskritisk kode og omfanget var langt større enn
en ren redirect-ombytting.

Full port: ekte POST /auth/request-link, POST /auth/login-password
(identisk LoginResult-statushåndtering som den gamle LoginForm), GET
/public/tournaments/by-code/{code}. TwoFactorVerifyForm/
TwoFactorSetupForm gjenbrukt UENDRET (lavest mulig risiko for
sikkerhetskritisk 2FA-kode). app/page.tsx redusert til en tynn
videresending (autentisert -> /dashboard//account, uautentisert ->
/logg-inn), beholdt for gamle bokmerker. Gammel LoginForm slettet.
Alle ~11 uautentisert-redirect-steder (committet i forrige commit)
pekte allerede til /logg-inn.

Én reell bug funnet og rettet under scratch-verifisering:
app/logg-inn/page.tsx kalte redirect() INNI en try/catch som fanget
Next.js sin egen NEXT_REDIRECT-kontrollflyt-exception stille -- en
allerede innlogget bruker fikk se innloggingsskjemaet på nytt i stedet
for å bli sendt videre. Rettet ved å flytte redirect()-kallet utenfor
try/catch, samme mønster som den opprinnelige /-siden alltid brukte.

Se CHANGELOG.md punkt 42 for full verifiseringsdetalj.
2026-08-08 12:34:50 +02:00

555 lines
21 KiB
TypeScript

"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 (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<rect x="3" y="5" width="18" height="14" rx="2.5" />
<path d="m4 7 8 6 8-6" />
</svg>
)
}
function KeyIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<circle cx="8" cy="15" r="4.5" />
<path d="M11 12 21 2m-4 1 3 3m-6 0 3 3" />
</svg>
)
}
function TicketIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="M3 8.5A1.5 1.5 0 0 1 4.5 7h15A1.5 1.5 0 0 1 21 8.5V10a2 2 0 0 0 0 4v1.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 15.5V14a2 2 0 0 0 0-4Z" />
<path d="M14 7v10" strokeDasharray="1.5 2.5" />
</svg>
)
}
function EyeIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}
function EyeOffIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="M3 3l18 18" />
<path d="M10.6 6.1A9.7 9.7 0 0 1 12 6c6 0 9.5 6 9.5 6a16 16 0 0 1-3.3 3.9M6.3 7.9A16 16 0 0 0 2.5 12S6 18 12 18a9.5 9.5 0 0 0 3.4-.6" />
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
</svg>
)
}
function ArrowLeftIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="M11 5 4 12l7 7M4 12h16" />
</svg>
)
}
function AlertIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="M12 3.5 1.8 20.5h20.4L12 3.5Z" />
<path d="M12 10v4.5" />
<circle cx="12" cy="17.6" r="0.4" fill="currentColor" stroke="none" />
</svg>
)
}
function CheckIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="m4 12.5 5 5 11-11" />
</svg>
)
}
function FlagIcon({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
<path d="M6 21V4" />
<path d="M6 4.5c3-2 6 1.5 9 0V13c-3 1.5-6-2-9 0" fill="var(--cup)" stroke="none" />
<path d="M6 4.5c3-2 6 1.5 9 0V13c-3 1.5-6-2-9 0" />
<circle cx="6" cy="21" r="1.6" fill="currentColor" stroke="none" />
</svg>
)
}
function Spinner({ className }: IconProps) {
return (
<svg viewBox="0 0 24 24" className={cn("animate-spin", className)} aria-hidden="true" fill="none">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2.5" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
)
}
/* ------------------------------------------------------------------ */
/* 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 (
<p id={id} role="alert" className="mt-1.5 flex items-start gap-1.5 text-[0.9375rem] font-medium text-cup-strong">
<AlertIcon className="mt-0.5 size-4 shrink-0" />
<span>{children}</span>
</p>
)
}
function FormAlert({ children }: { children: React.ReactNode }) {
return (
<div
role="alert"
className="flex items-start gap-2.5 rounded-xl border border-cup-strong/45 bg-cup/10 px-4 py-3 text-[0.9375rem] leading-snug text-clubhouse-ink"
>
<AlertIcon className="mt-0.5 size-5 shrink-0 text-cup-strong" />
<span>{children}</span>
</div>
)
}
/* ------------------------------------------------------------------ */
/* Main component */
/* ------------------------------------------------------------------ */
export function TeeCupAuth() {
const router = useRouter()
const [mode, setMode] = useState<Mode>("email")
const [postAuth, setPostAuth] = useState<PostAuthMode>(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<string | null>(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 (
<TwoFactorVerifyForm
method={twoFactorMethod}
onSuccess={handleTwoFactorSuccess}
onBack={() => setPostAuth(null)}
/>
)
}
if (postAuth === "2fa-setup") {
return <TwoFactorSetupForm forced onSuccess={handleTwoFactorSuccess} />
}
return (
<div>
{/* ---------- State 1: Magic link (primary path) ---------- */}
{mode === "email" && (
<form onSubmit={handleSendLink} noValidate>
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Logg inn</h2>
<p className="mt-1.5 text-pretty text-base leading-relaxed text-clubhouse-muted">
Klart for en runde? Skriv inn e-posten din, sender vi deg en lenke.
</p>
<div className="mt-6">
<label htmlFor="email" className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
E-post
</label>
<input
id="email"
name="email"
type="email"
inputMode="email"
autoComplete="email"
autoFocus
enterKeyHint="go"
placeholder="deg@eksempel.no"
value={email}
onChange={(e) => 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 && <FieldError id={emailErrId}>Skriv inn en gyldig e-postadresse, f.eks. navn@klubb.no.</FieldError>}
</div>
{formError && <div className="mt-4">{<FormAlert>{formError}</FormAlert>}</div>}
<button type="submit" disabled={loading} className={cn(primaryBtn, "mt-5")}>
{loading ? <Spinner className="size-5" /> : <MailIcon className="size-5" />}
{loading ? "Sender …" : "Send innloggingslenke"}
</button>
<p className="mt-3 text-pretty text-[0.9375rem] leading-relaxed text-clubhouse-muted">
Ingen passord nødvendig. Vi sender deg en sikker lenke e-post.
</p>
<div className="mt-7 border-t border-clubhouse-border pt-5">
<div className="grid gap-2.5">
<button type="button" className={secondaryBtn} onClick={() => switchMode("password")}>
<KeyIcon className="size-5 text-tee-strong" />
Logg inn med e-post og passord i stedet
</button>
<button type="button" className={secondaryBtn} onClick={() => switchMode("code")}>
<TicketIcon className="size-5 text-cup-strong" />
Har du en invitasjonskode? rett til turneringen
</button>
</div>
</div>
</form>
)}
{/* ---------- State 2: Link sent ---------- */}
{mode === "sent" && (
<div>
<div className="mb-5 flex size-14 items-center justify-center rounded-2xl bg-tee/20 text-tee-strong">
<MailIcon className="size-7" />
</div>
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Lenke sendt</h2>
<p className="mt-2 text-pretty text-base leading-relaxed text-clubhouse-ink">
Vi sendte en innloggingslenke til <span className="font-semibold break-all">{email}</span>.
</p>
<p className="mt-2 text-pretty text-[0.9375rem] leading-relaxed text-clubhouse-muted">
Åpne lenken denne enheten for å logge inn. Det kan ta et minutt før den kommer frem.
</p>
{formError && <div className="mt-4">{<FormAlert>{formError}</FormAlert>}</div>}
{resentNote && !formError && (
<p role="status" className="mt-4 flex items-center gap-2 text-[0.9375rem] font-medium text-tee-strong">
<CheckIcon className="size-5 shrink-0" />
Lenken er sendt nytt.
</p>
)}
<button type="button" onClick={handleResend} disabled={cooldown > 0 || loading} className={cn(primaryBtn, "mt-6")}>
{loading ? <Spinner className="size-5" /> : <MailIcon className="size-5" />}
{loading ? "Sender …" : cooldown > 0 ? `Send igjen om ${cooldown}s` : "Send lenken igjen"}
</button>
<div className="mt-6">
<button type="button" className={backBtn} onClick={() => switchMode("email", { clearEmail: true })}>
<ArrowLeftIcon className="size-5" />
Bruk en annen e-postadresse
</button>
</div>
</div>
)}
{/* ---------- State 3: Password fallback ---------- */}
{mode === "password" && (
<form onSubmit={handlePasswordLogin} noValidate>
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Logg inn med passord</h2>
<p className="mt-1.5 text-pretty text-base leading-relaxed text-clubhouse-muted">
For deg som har satt et passord kontoen din.
</p>
{formError && <div className="mt-5">{<FormAlert>{formError}</FormAlert>}</div>}
<div className="mt-5">
<label htmlFor="email-pw" className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
E-post
</label>
<input
id="email-pw"
name="email"
type="email"
inputMode="email"
autoComplete="email"
autoFocus
placeholder="deg@eksempel.no"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={cn(inputBase, "border-clubhouse-border")}
/>
</div>
<div className="mt-4">
<label htmlFor={pwIds} className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
Passord
</label>
<div className="relative">
<input
id={pwIds}
name="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder="Passordet ditt"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={cn(inputBase, "border-clubhouse-border pr-14")}
/>
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
aria-label={showPassword ? "Skjul passord" : "Vis passord"}
aria-pressed={showPassword}
className={cn(
"absolute right-1 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-lg text-clubhouse-muted transition-colors hover:text-clubhouse-ink",
focusRing,
)}
>
{showPassword ? <EyeOffIcon className="size-5" /> : <EyeIcon className="size-5" />}
</button>
</div>
</div>
<button type="submit" disabled={loading || !email.trim() || !password} className={cn(primaryBtn, "mt-6")}>
{loading ? <Spinner className="size-5" /> : <KeyIcon className="size-5" />}
{loading ? "Logger inn …" : "Logg inn"}
</button>
<div className="mt-6">
<button type="button" className={backBtn} onClick={() => switchMode("email")}>
<ArrowLeftIcon className="size-5" />
Bruk innloggingslenke e-post i stedet
</button>
</div>
</form>
)}
{/* ---------- State 4: Join by code ---------- */}
{mode === "code" && (
<form onSubmit={handleJoin} noValidate>
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Bli med via kode</h2>
<p className="mt-1.5 text-pretty text-base leading-relaxed text-clubhouse-muted">
Fått en kode muntlig eller en lapp? Skriv den inn her du trenger ikke logge inn for å se turneringen eller melde deg .
</p>
{formError && <div className="mt-5">{<FormAlert>{formError}</FormAlert>}</div>}
<div className="mt-5">
<label htmlFor="invite" className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
Invitasjonskode
</label>
<input
id="invite"
name="invite"
type="text"
autoFocus
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
enterKeyHint="go"
placeholder="F.EKS. RYDER-25"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
className={cn(inputBase, "border-clubhouse-border font-mono tracking-[0.15em] uppercase")}
/>
</div>
<button type="submit" disabled={loading || !code.trim()} className={cn(primaryBtn, "mt-6")}>
{loading ? <Spinner className="size-5" /> : <FlagIcon className="size-5" />}
{loading ? "Sjekker …" : "Gå til turnering"}
</button>
<div className="mt-6">
<button type="button" className={backBtn} onClick={() => switchMode("email")}>
<ArrowLeftIcon className="size-5" />
Tilbake til innlogging
</button>
</div>
</form>
)}
</div>
)
}