teecup/frontend/components/two-factor-flow.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

371 lines
12 KiB
TypeScript

"use client"
// Delt mellom teecup-auth.tsx (passord-modus, /logg-inn) og verify-form.tsx
// (magic-link) -- begge kan returnere status "2fa_required"/
// "2fa_setup_required" fra backend (ADR-021 Beslutning E), og trenger
// identisk oppfølging.
import type React from "react"
import { useEffect, useState } from "react"
import { ArrowLeft, KeyRound, Mail, ShieldCheck } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
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"
}
// --- Verifisering av en PÅGÅENDE innlogging (status "2fa_required") --------
export function TwoFactorVerifyForm({
method,
onSuccess,
onBack,
}: {
method: "totp" | "email"
onSuccess: (user: SessionUser) => void
onBack: () => void
}) {
const [code, setCode] = useState("")
const [submitting, setSubmitting] = useState(false)
const [sendingEmail, setSendingEmail] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const [error, setError] = useState<string | null>(null)
async function requestEmailCode() {
setSendingEmail(true)
setError(null)
try {
const res = await fetch("/auth/2fa/email/request", { method: "POST", credentials: "include" })
if (!res.ok) throw new Error("request failed")
setEmailSent(true)
} catch {
setError("Klarte ikke å sende koden. Prøv igjen.")
} finally {
setSendingEmail(false)
}
}
useEffect(() => {
if (method === "email") void requestEmailCode()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [method])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!code.trim() || submitting) return
setSubmitting(true)
setError(null)
try {
const res = await fetch("/auth/2fa/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ code: code.trim() }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Feil eller utløpt kode.")
}
const result: LoginResult = await res.json()
if (result.status === "success" && result.user) {
onSuccess(result.user)
} else {
throw new Error("Uventet svar fra serveren.")
}
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<h2 className="text-xl font-extrabold tracking-tight">Topartsautentisering</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{method === "totp"
? "Skriv inn koden fra autentisator-appen din."
: emailSent
? "Vi har sendt en engangskode til e-posten din."
: "Sender en engangskode til e-posten din …"}
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="tfa-code" className="text-sm font-semibold">
Kode
</Label>
<div className="relative">
{method === "totp" ? (
<KeyRound
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
) : (
<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="tfa-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value)}
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={submitting || !code.trim()}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
{submitting ? "Bekrefter …" : "Bekreft"}
</Button>
{method === "email" && (
<button
type="button"
onClick={requestEmailCode}
disabled={sendingEmail}
className="text-center text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
{sendingEmail ? "Sender …" : "Send koden på nytt"}
</button>
)}
<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>
)
}
// --- Oppsett av 2FA (status "2fa_setup_required" ELLER frivillig fra
// kontoinnstillinger) -------------------------------------------------
type SetupStartResult = {
status: "totp_ready" | "email_sent"
secret?: string
otpauth_uri?: string
qr_code_data_uri?: string
}
export function TwoFactorSetupForm({
forced,
onSuccess,
}: {
forced: boolean
onSuccess: (user: SessionUser) => void
}) {
const [method, setMethod] = useState<"totp" | "email" | null>(null)
const [starting, setStarting] = useState(false)
const [setupInfo, setSetupInfo] = useState<SetupStartResult | null>(null)
const [code, setCode] = useState("")
const [confirming, setConfirming] = useState(false)
const [error, setError] = useState<string | null>(null)
async function chooseMethod(chosen: "totp" | "email") {
setMethod(chosen)
setStarting(true)
setError(null)
try {
const res = await fetch("/auth/2fa/setup/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ method: chosen }),
})
if (!res.ok) throw new Error("start failed")
setSetupInfo(await res.json())
} catch {
setError("Klarte ikke å starte oppsettet. Prøv igjen.")
setMethod(null)
} finally {
setStarting(false)
}
}
async function handleConfirm(e: React.FormEvent) {
e.preventDefault()
if (!method || !code.trim() || confirming) return
setConfirming(true)
setError(null)
try {
const res = await fetch("/auth/2fa/setup/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ method, code: code.trim(), secret: setupInfo?.secret }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Feil kode.")
}
const user: SessionUser = await res.json()
onSuccess(user)
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setConfirming(false)
}
}
if (!method) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<h2 className="text-xl font-extrabold tracking-tight">Sett opp topartsautentisering</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{forced
? "Som organisasjonseier/administrator må du sette opp 2FA før du kan fortsette."
: "Velg hvilken metode du ønsker."}
</p>
</div>
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
{error}
</p>
)}
<div className="flex flex-col gap-3">
<Button
type="button"
disabled={starting}
onClick={() => void chooseMethod("totp")}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
<KeyRound aria-hidden="true" className="size-5" />
Autentisator-app (TOTP)
</Button>
<Button
type="button"
variant="outline"
disabled={starting}
onClick={() => void chooseMethod("email")}
className="h-14 rounded-2xl text-base font-bold"
>
<Mail aria-hidden="true" className="size-5" />
Engangskode e-post
</Button>
</div>
</div>
)
}
return (
<form onSubmit={handleConfirm} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<h2 className="text-xl font-extrabold tracking-tight">Bekreft oppsettet</h2>
</div>
{method === "totp" && setupInfo?.qr_code_data_uri && (
<div className="flex flex-col items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element -- data-URI, ingen next/image-fordel */}
<img
src={setupInfo.qr_code_data_uri || "/placeholder.svg"}
alt="QR-kode for autentisator-app"
className="size-48 rounded-2xl border border-border"
/>
<p className="text-center text-xs leading-relaxed text-muted-foreground text-pretty">
Skann QR-koden med autentisator-appen din, eller skriv inn denne koden manuelt:{" "}
<span className="font-mono font-semibold text-foreground">{setupInfo.secret}</span>
</p>
</div>
)}
{method === "email" && (
<p className="text-center text-sm text-muted-foreground text-pretty">
Vi har sendt en engangskode til e-posten din.
</p>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="setup-code" className="text-sm font-semibold">
Bekreftelseskode
</Label>
<Input
id="setup-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value)}
className="h-14 rounded-2xl text-base tracking-widest"
/>
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
{error}
</p>
)}
<Button
type="submit"
disabled={confirming || !code.trim()}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
{confirming ? "Bekrefter …" : "Aktiver 2FA"}
</Button>
<button
type="button"
onClick={() => {
setMethod(null)
setSetupInfo(null)
setCode("")
}}
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" />
Velg en annen metode
</button>
</form>
)
}