Legge til argon2-cffi, pyotp, qrcode i requirements.txt Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner) app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering app/email.py: 2FA-kode og invitasjons-maler app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti Frontend: login-form passord-modus + 2FA-skjermer Frontend: kontoinnstillinger + org-medlemsstyring-skjerm Ekte typesjekket frontend-build Scratch-verifisere hele auth-løpet grundig (backend) Deploy mot ekte teecup_db/containere + oppdatere .md-filer Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt. Klar til utrulling mot ekte systemer: Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db Redeploy: både teecup_api og teecup_frontend AskUserQuestion
489 lines
16 KiB
TypeScript
489 lines
16 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, Lock } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { TwoFactorSetupForm, TwoFactorVerifyForm } from "@/components/two-factor-flow"
|
|
|
|
const RESEND_COOLDOWN = 30 // seconds
|
|
|
|
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"
|
|
}
|
|
|
|
function isValidEmail(value: string) {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
|
|
}
|
|
|
|
// ADR-021 Beslutning E: primær-autentisering (magic-link ELLER passord) kan
|
|
// returnere tre ulike utfall -- delt mellom denne komponenten og
|
|
// verify-form.tsx sin håndtering av magic-link-svaret.
|
|
type PostAuthMode = "2fa-verify" | "2fa-setup" | null
|
|
|
|
export function LoginForm() {
|
|
const router = useRouter()
|
|
const [mode, setMode] = useState<"email" | "code" | "password">("email")
|
|
const [postAuth, setPostAuth] = useState<PostAuthMode>(null)
|
|
const [twoFactorMethod, setTwoFactorMethod] = useState<"totp" | "email" | null>(null)
|
|
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)
|
|
|
|
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")
|
|
}
|
|
|
|
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">
|
|
{postAuth === "2fa-verify" ? (
|
|
<TwoFactorVerifyForm
|
|
method={twoFactorMethod ?? "totp"}
|
|
onSuccess={handleTwoFactorSuccess}
|
|
onBack={() => setPostAuth(null)}
|
|
/>
|
|
) : postAuth === "2fa-setup" ? (
|
|
<TwoFactorSetupForm forced onSuccess={handleTwoFactorSuccess} />
|
|
) : sent ? (
|
|
<ConfirmationState
|
|
email={email}
|
|
cooldown={cooldown}
|
|
sending={sending}
|
|
error={error}
|
|
onResend={handleResend}
|
|
onReset={handleReset}
|
|
/>
|
|
) : mode === "code" ? (
|
|
<JoinByCode onBack={() => setMode("email")} />
|
|
) : mode === "password" ? (
|
|
<PasswordLoginForm onResult={handleLoginResult} 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 nødvendig. Vi sender deg en sikker lenke på e-post.
|
|
</p>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode("password")}
|
|
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<Lock aria-hidden="true" className="size-4" />
|
|
Logg inn med e-post og passord i stedet
|
|
</button>
|
|
<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>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Passord-innlogging (ADR-021 Beslutning A) -- et sidestilt, valgfritt
|
|
// alternativ til magic-link, ALDRI en erstatning. -----------------------
|
|
|
|
function PasswordLoginForm({
|
|
onResult,
|
|
onBack,
|
|
}: {
|
|
onResult: (result: LoginResult) => void
|
|
onBack: () => void
|
|
}) {
|
|
const [email, setEmail] = useState("")
|
|
const [password, setPassword] = useState("")
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!email.trim() || !password || submitting) return
|
|
setSubmitting(true)
|
|
setError(null)
|
|
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.")
|
|
}
|
|
onResult(await res.json())
|
|
} 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 gap-2">
|
|
<Label htmlFor="pw-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="pw-email"
|
|
type="email"
|
|
inputMode="email"
|
|
autoComplete="email"
|
|
autoFocus
|
|
placeholder="deg@epost.no"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className="h-14 rounded-2xl pl-11 text-base"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="pw-password" className="text-sm font-semibold">
|
|
Passord
|
|
</Label>
|
|
<div className="relative">
|
|
<Lock
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
|
|
/>
|
|
{/* Bevisst ingen begrensning på tegn -- spesialtegn/mellomrom skal fungere. */}
|
|
<Input
|
|
id="pw-password"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
placeholder="Passordet ditt"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="h-14 rounded-2xl pl-11 text-base"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<p role="alert" className="text-center text-sm font-medium text-destructive">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<Button
|
|
type="submit"
|
|
disabled={submitting || !email.trim() || !password}
|
|
className="h-14 rounded-2xl text-base font-bold shadow-sm"
|
|
>
|
|
{submitting ? "Logger inn …" : "Logg inn"}
|
|
</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" />
|
|
Bruk innloggingslenke på e-post i stedet
|
|
</button>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
// --- 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>
|
|
)
|
|
}
|
|
|