teecup/frontend/components/verify-form.tsx

183 lines
6.1 KiB
TypeScript
Raw Normal View History

"use client"
import type React from "react"
import { useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { CheckCircle2, KeyRound } 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"
type Status = "checking" | "verifying" | "manual" | "success" | "error" | "2fa-verify" | "2fa-setup"
type LoginResult = {
status: "success" | "2fa_required" | "2fa_setup_required"
user?: { email: string }
two_factor_method?: "totp" | "email"
}
export function VerifyForm() {
const router = useRouter()
const searchParams = useSearchParams()
const urlToken = searchParams.get("token")
const [status, setStatus] = useState<Status>(urlToken ? "checking" : "manual")
const [manualToken, setManualToken] = useState("")
const [email, setEmail] = useState<string | null>(null)
const [twoFactorMethod, setTwoFactorMethod] = useState<"totp" | "email">("totp")
const [error, setError] = useState<string | null>(null)
async function verify(token: string) {
setStatus("verifying")
setError(null)
try {
const res = await fetch("/auth/verify-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ token }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Lenken er ugyldig eller utløpt.")
}
const result: LoginResult = await res.json()
if (result.status === "2fa_required") {
setTwoFactorMethod(result.two_factor_method ?? "totp")
setStatus("2fa-verify")
return
}
if (result.status === "2fa_setup_required") {
setStatus("2fa-setup")
return
}
setEmail(result.user?.email ?? null)
setStatus("success")
// Kort pause så brukeren rekker å se bekreftelsen før vi går videre.
setTimeout(() => router.replace("/dashboard"), 900)
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
setStatus("error")
}
}
function handleTwoFactorSuccess() {
router.replace("/dashboard")
}
useEffect(() => {
if (urlToken) void verify(urlToken)
// Kjør kun ved mount / når URL-tokenet faktisk endres.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlToken])
function handleManualSubmit(e: React.FormEvent) {
e.preventDefault()
if (!manualToken.trim()) return
void verify(manualToken.trim())
}
if (status === "2fa-verify") {
return (
<div className="w-full rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
<TwoFactorVerifyForm
method={twoFactorMethod}
onSuccess={handleTwoFactorSuccess}
onBack={() => setStatus("manual")}
/>
</div>
)
}
if (status === "2fa-setup") {
return (
<div className="w-full rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
<TwoFactorSetupForm forced onSuccess={handleTwoFactorSuccess} />
</div>
)
}
if (status === "checking" || status === "verifying") {
return (
<div className="flex w-full flex-col items-center gap-4 rounded-3xl border border-border bg-card p-8 text-center shadow-lg shadow-black/5">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
<p className="text-sm text-muted-foreground" role="status">
Logger deg inn
</p>
</div>
)
}
if (status === "success") {
return (
<div className="flex w-full flex-col items-center gap-5 rounded-3xl border border-border bg-card p-6 text-center shadow-lg shadow-black/5 sm:p-8">
<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 className="text-2xl font-extrabold tracking-tight text-balance">Du er innlogget</h2>
{email && (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Logget inn som <span className="font-semibold text-foreground">{email}</span>.
</p>
)}
</div>
</div>
)
}
// "manual" eller "error" -- samme skjema, ev. med en feilmelding over knappen.
return (
<div className="w-full rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
<form onSubmit={handleManualSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2">
<Label htmlFor="token" className="text-sm font-semibold">
Kode fra e-posten
</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="token"
type="text"
autoComplete="one-time-code"
autoFocus
placeholder="Lim inn koden"
value={manualToken}
onChange={(e) => setManualToken(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={!manualToken.trim()}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
Logg inn
</Button>
<a
2026-08-08 12:34:50 +02:00
href="/logg-inn"
className="text-center text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
Be om en ny lenke
</a>
</form>
</div>
)
}