teecup/frontend/components/login-form.tsx

220 lines
6.5 KiB
TypeScript
Raw Normal View History

2026-07-17 21:40:42 +02:00
"use client"
import type React from "react"
import { useEffect, useRef, useState } from "react"
import { Mail, ArrowLeft, CheckCircle2 } from "lucide-react"
2026-07-17 21:40:42 +02:00
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
const RESEND_COOLDOWN = 30 // seconds
function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
}
export function LoginForm() {
const [email, setEmail] = useState("")
const [touched, setTouched] = useState(false)
const [sent, setSent] = useState(false)
const [sending, setSending] = useState(false)
const [cooldown, setCooldown] = useState(0)
2026-07-17 21:57:44 +02:00
const [error, setError] = useState<string | null>(null)
2026-07-17 21:40:42 +02:00
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)
2026-07-17 21:57:44 +02:00
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)
}
2026-07-17 21:40:42 +02:00
}
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">
{sent ? (
<ConfirmationState
email={email}
cooldown={cooldown}
sending={sending}
2026-07-17 21:57:44 +02:00
error={error}
2026-07-17 21:40:42 +02:00
onResend={handleResend}
onReset={handleReset}
/>
) : (
<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>
2026-07-17 21:57:44 +02:00
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
{error}
</p>
)}
2026-07-17 21:40:42 +02:00
<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. Vi sender deg en sikker lenke e-post.
</p>
</form>
)}
</div>
)
}
function ConfirmationState({
email,
cooldown,
sending,
2026-07-17 21:57:44 +02:00
error,
2026-07-17 21:40:42 +02:00
onResend,
onReset,
}: {
email: string
cooldown: number
sending: boolean
2026-07-17 21:57:44 +02:00
error: string | null
2026-07-17 21:40:42 +02:00
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 denne enheten for å logge inn.
</p>
</div>
2026-07-17 21:57:44 +02:00
{error && (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
)}
2026-07-17 21:40:42 +02:00
<div className="mt-1 w-full">
{cooldown > 0 ? (
<p className="text-sm text-muted-foreground" aria-live="polite">
Fikk du ingen e-post? Send 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>
)
}