dashboard.tsx sitt datalag skrevet om fra V0s mock-useState til ekte kall: /auth/me (organisasjoner), /orgs/{id}/tournaments (turneringer per valgt org), POST /orgs og POST /orgs/{id}/tournaments (opprettelse), POST /auth/logout.
/verify sender deg nå videre til /dashboard etter innlogging (fantes ingen dit å gå før).
login-form.tsx fikk kun en kirurgisk patch (Wordmark flyttet til egen fil, som V0 selv gjorde) — din egen fetch-/feillogikk urørt.
Verifisert: ekte typesjekket build, redeploy av kun teecup_frontend (ingen Caddy-endring trengtes denne gangen — mønsteret fra sist holder), /dashboard → 200 live, teeoff.no upåvirket.
219 lines
6.5 KiB
TypeScript
219 lines
6.5 KiB
TypeScript
"use client"
|
|
|
|
import type React from "react"
|
|
import { useEffect, useRef, useState } from "react"
|
|
import { Mail, ArrowLeft, CheckCircle2 } from "lucide-react"
|
|
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)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
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">
|
|
{sent ? (
|
|
<ConfirmationState
|
|
email={email}
|
|
cooldown={cooldown}
|
|
sending={sending}
|
|
error={error}
|
|
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>
|
|
|
|
{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. Vi sender deg en sikker lenke på e-post.
|
|
</p>
|
|
</form>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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>
|
|
)
|
|
}
|
|
|