/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.
555 lines
21 KiB
TypeScript
555 lines
21 KiB
TypeScript
"use client"
|
|
|
|
import type React from "react"
|
|
import { useEffect, useId, useState } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { cn } from "@/lib/utils"
|
|
import { TwoFactorSetupForm, TwoFactorVerifyForm } from "@/components/two-factor-flow"
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Icons — inline SVG (currentColor), so every action can pair an */
|
|
/* icon with a real text label and we control size/stroke precisely. */
|
|
/* ------------------------------------------------------------------ */
|
|
type IconProps = { className?: string }
|
|
const stroke = {
|
|
fill: "none",
|
|
stroke: "currentColor",
|
|
strokeWidth: 1.75,
|
|
strokeLinecap: "round" as const,
|
|
strokeLinejoin: "round" as const,
|
|
}
|
|
function MailIcon({ className }: IconProps) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
|
<rect x="3" y="5" width="18" height="14" rx="2.5" />
|
|
<path d="m4 7 8 6 8-6" />
|
|
</svg>
|
|
)
|
|
}
|
|
function KeyIcon({ className }: IconProps) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
|
<circle cx="8" cy="15" r="4.5" />
|
|
<path d="M11 12 21 2m-4 1 3 3m-6 0 3 3" />
|
|
</svg>
|
|
)
|
|
}
|
|
function TicketIcon({ className }: IconProps) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
|
<path d="M3 8.5A1.5 1.5 0 0 1 4.5 7h15A1.5 1.5 0 0 1 21 8.5V10a2 2 0 0 0 0 4v1.5a1.5 1.5 0 0 1-1.5 1.5h-15A1.5 1.5 0 0 1 3 15.5V14a2 2 0 0 0 0-4Z" />
|
|
<path d="M14 7v10" strokeDasharray="1.5 2.5" />
|
|
</svg>
|
|
)
|
|
}
|
|
function EyeIcon({ className }: IconProps) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
|
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
)
|
|
}
|
|
function EyeOffIcon({ className }: IconProps) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
|
<path d="M3 3l18 18" />
|
|
<path d="M10.6 6.1A9.7 9.7 0 0 1 12 6c6 0 9.5 6 9.5 6a16 16 0 0 1-3.3 3.9M6.3 7.9A16 16 0 0 0 2.5 12S6 18 12 18a9.5 9.5 0 0 0 3.4-.6" />
|
|
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
|
|
</svg>
|
|
)
|
|
}
|
|
function ArrowLeftIcon({ className }: IconProps) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
|
<path d="M11 5 4 12l7 7M4 12h16" />
|
|
</svg>
|
|
)
|
|
}
|
|
function AlertIcon({ className }: IconProps) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
|
<path d="M12 3.5 1.8 20.5h20.4L12 3.5Z" />
|
|
<path d="M12 10v4.5" />
|
|
<circle cx="12" cy="17.6" r="0.4" fill="currentColor" stroke="none" />
|
|
</svg>
|
|
)
|
|
}
|
|
function CheckIcon({ className }: IconProps) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
|
<path d="m4 12.5 5 5 11-11" />
|
|
</svg>
|
|
)
|
|
}
|
|
function FlagIcon({ className }: IconProps) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className={className} aria-hidden="true" {...stroke}>
|
|
<path d="M6 21V4" />
|
|
<path d="M6 4.5c3-2 6 1.5 9 0V13c-3 1.5-6-2-9 0" fill="var(--cup)" stroke="none" />
|
|
<path d="M6 4.5c3-2 6 1.5 9 0V13c-3 1.5-6-2-9 0" />
|
|
<circle cx="6" cy="21" r="1.6" fill="currentColor" stroke="none" />
|
|
</svg>
|
|
)
|
|
}
|
|
function Spinner({ className }: IconProps) {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className={cn("animate-spin", className)} aria-hidden="true" fill="none">
|
|
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2.5" />
|
|
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Shared styles */
|
|
/* ------------------------------------------------------------------ */
|
|
const focusRing =
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tee-strong focus-visible:ring-offset-2 focus-visible:ring-offset-clubhouse-card"
|
|
|
|
const inputBase =
|
|
"w-full h-12 rounded-xl border bg-clubhouse-field px-4 text-base text-clubhouse-ink placeholder:text-clubhouse-muted/70 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-tee-strong/70"
|
|
|
|
const primaryBtn = cn(
|
|
"inline-flex w-full min-h-[3.25rem] items-center justify-center gap-2 rounded-xl bg-tee-strong px-5 text-base font-semibold text-white shadow-sm transition-colors hover:bg-[#265a18] active:translate-y-px disabled:cursor-not-allowed disabled:opacity-55",
|
|
focusRing,
|
|
)
|
|
|
|
const secondaryBtn = cn(
|
|
"inline-flex w-full min-h-[2.75rem] items-center justify-center gap-2 rounded-xl border border-clubhouse-border bg-clubhouse-card px-4 py-2.5 text-base font-medium text-clubhouse-ink transition-colors hover:border-tee-strong/40 hover:bg-clubhouse-field",
|
|
focusRing,
|
|
)
|
|
|
|
const backBtn = cn(
|
|
"-mx-2 inline-flex min-h-[2.75rem] items-center gap-1.5 rounded-lg px-2 text-base font-medium text-tee-strong transition-colors hover:underline",
|
|
focusRing,
|
|
)
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
const RESEND_SECONDS = 30
|
|
|
|
type Mode = "email" | "sent" | "password" | "code"
|
|
type PostAuthMode = "2fa-verify" | "2fa-setup" | null
|
|
|
|
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"
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Small building blocks */
|
|
/* ------------------------------------------------------------------ */
|
|
function FieldError({ id, children }: { id: string; children: React.ReactNode }) {
|
|
return (
|
|
<p id={id} role="alert" className="mt-1.5 flex items-start gap-1.5 text-[0.9375rem] font-medium text-cup-strong">
|
|
<AlertIcon className="mt-0.5 size-4 shrink-0" />
|
|
<span>{children}</span>
|
|
</p>
|
|
)
|
|
}
|
|
|
|
function FormAlert({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div
|
|
role="alert"
|
|
className="flex items-start gap-2.5 rounded-xl border border-cup-strong/45 bg-cup/10 px-4 py-3 text-[0.9375rem] leading-snug text-clubhouse-ink"
|
|
>
|
|
<AlertIcon className="mt-0.5 size-5 shrink-0 text-cup-strong" />
|
|
<span>{children}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Main component */
|
|
/* ------------------------------------------------------------------ */
|
|
export function TeeCupAuth() {
|
|
const router = useRouter()
|
|
const [mode, setMode] = useState<Mode>("email")
|
|
const [postAuth, setPostAuth] = useState<PostAuthMode>(null)
|
|
const [twoFactorMethod, setTwoFactorMethod] = useState<"totp" | "email">("totp")
|
|
|
|
// shared field state (email persists across email <-> password)
|
|
const [email, setEmail] = useState("")
|
|
const [emailTouched, setEmailTouched] = useState(false)
|
|
|
|
const emailInvalid = emailTouched && email.length > 0 && !EMAIL_RE.test(email)
|
|
const emailIds = useId()
|
|
const emailErrId = `${emailIds}-email-err`
|
|
|
|
/* form-level (server) error, shared across states */
|
|
const [formError, setFormError] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
|
|
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")
|
|
}
|
|
|
|
/* ---- State 1: magic link, ekte /auth/request-link ---- */
|
|
async function handleSendLink(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setEmailTouched(true)
|
|
if (!EMAIL_RE.test(email) || loading) return
|
|
setFormError(null)
|
|
setLoading(true)
|
|
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}`)
|
|
setMode("sent")
|
|
setCooldown(RESEND_SECONDS)
|
|
} catch {
|
|
setFormError("Klarte ikke å sende lenken. Sjekk tilkoblingen og prøv igjen.")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
/* ---- State 2: cooldown ---- */
|
|
const [cooldown, setCooldown] = useState(0)
|
|
const [resentNote, setResentNote] = useState(false)
|
|
useEffect(() => {
|
|
if (cooldown <= 0) return
|
|
const t = setInterval(() => setCooldown((c) => (c <= 1 ? 0 : c - 1)), 1000)
|
|
return () => clearInterval(t)
|
|
}, [cooldown])
|
|
|
|
async function handleResend() {
|
|
if (cooldown > 0 || loading) return
|
|
setResentNote(false)
|
|
setFormError(null)
|
|
setLoading(true)
|
|
try {
|
|
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}`)
|
|
setCooldown(RESEND_SECONDS)
|
|
setResentNote(true)
|
|
} catch {
|
|
setFormError("Klarte ikke å sende lenken. Sjekk tilkoblingen og prøv igjen.")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
/* ---- State 3: password, ekte /auth/login-password ---- */
|
|
const [password, setPassword] = useState("")
|
|
const [showPassword, setShowPassword] = useState(false)
|
|
const pwIds = useId()
|
|
|
|
async function handlePasswordLogin(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!email.trim() || !password || loading) return
|
|
setFormError(null)
|
|
setLoading(true)
|
|
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.")
|
|
}
|
|
handleLoginResult(await res.json())
|
|
} catch (err) {
|
|
setFormError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
/* ---- State 4: join by code, ekte /public/tournaments/by-code ---- */
|
|
const [code, setCode] = useState("")
|
|
async function handleJoin(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
const trimmed = code.trim()
|
|
if (!trimmed || loading) return
|
|
setFormError(null)
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch(`/public/tournaments/by-code/${encodeURIComponent(trimmed)}`)
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => null)
|
|
throw new Error(body?.detail?.message ?? "Fant ingen turnering med denne koden.")
|
|
}
|
|
const data: { tournament_id: string } = await res.json()
|
|
router.push(`/t/${data.tournament_id}?code=${encodeURIComponent(trimmed)}`)
|
|
} catch (err) {
|
|
setFormError(err instanceof Error ? err.message : "Klarte ikke å slå opp koden. Sjekk tilkoblingen og prøv igjen.")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// Reset transient per-mode state when switching modes.
|
|
function switchMode(next: Mode, opts?: { clearEmail?: boolean }) {
|
|
setFormError(null)
|
|
setPassword("")
|
|
setShowPassword(false)
|
|
setResentNote(false)
|
|
if (opts?.clearEmail) {
|
|
setEmail("")
|
|
setEmailTouched(false)
|
|
}
|
|
if (next === "code") setCode("")
|
|
setMode(next)
|
|
}
|
|
|
|
/* ---------------- 2FA continuation (shared with password login) --- */
|
|
if (postAuth === "2fa-verify") {
|
|
return (
|
|
<TwoFactorVerifyForm
|
|
method={twoFactorMethod}
|
|
onSuccess={handleTwoFactorSuccess}
|
|
onBack={() => setPostAuth(null)}
|
|
/>
|
|
)
|
|
}
|
|
if (postAuth === "2fa-setup") {
|
|
return <TwoFactorSetupForm forced onSuccess={handleTwoFactorSuccess} />
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{/* ---------- State 1: Magic link (primary path) ---------- */}
|
|
{mode === "email" && (
|
|
<form onSubmit={handleSendLink} noValidate>
|
|
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Logg inn</h2>
|
|
<p className="mt-1.5 text-pretty text-base leading-relaxed text-clubhouse-muted">
|
|
Klart for en runde? Skriv inn e-posten din, så sender vi deg en lenke.
|
|
</p>
|
|
|
|
<div className="mt-6">
|
|
<label htmlFor="email" className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
|
|
E-post
|
|
</label>
|
|
<input
|
|
id="email"
|
|
name="email"
|
|
type="email"
|
|
inputMode="email"
|
|
autoComplete="email"
|
|
autoFocus
|
|
enterKeyHint="go"
|
|
placeholder="deg@eksempel.no"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
onBlur={() => setEmailTouched(true)}
|
|
aria-invalid={emailInvalid || undefined}
|
|
aria-describedby={emailInvalid ? emailErrId : undefined}
|
|
className={cn(inputBase, emailInvalid ? "border-cup-strong ring-2 ring-cup-strong/30" : "border-clubhouse-border")}
|
|
/>
|
|
{emailInvalid && <FieldError id={emailErrId}>Skriv inn en gyldig e-postadresse, f.eks. navn@klubb.no.</FieldError>}
|
|
</div>
|
|
|
|
{formError && <div className="mt-4">{<FormAlert>{formError}</FormAlert>}</div>}
|
|
|
|
<button type="submit" disabled={loading} className={cn(primaryBtn, "mt-5")}>
|
|
{loading ? <Spinner className="size-5" /> : <MailIcon className="size-5" />}
|
|
{loading ? "Sender …" : "Send innloggingslenke"}
|
|
</button>
|
|
<p className="mt-3 text-pretty text-[0.9375rem] leading-relaxed text-clubhouse-muted">
|
|
Ingen passord nødvendig. Vi sender deg en sikker lenke på e-post.
|
|
</p>
|
|
|
|
<div className="mt-7 border-t border-clubhouse-border pt-5">
|
|
<div className="grid gap-2.5">
|
|
<button type="button" className={secondaryBtn} onClick={() => switchMode("password")}>
|
|
<KeyIcon className="size-5 text-tee-strong" />
|
|
Logg inn med e-post og passord i stedet
|
|
</button>
|
|
<button type="button" className={secondaryBtn} onClick={() => switchMode("code")}>
|
|
<TicketIcon className="size-5 text-cup-strong" />
|
|
Har du en invitasjonskode? Gå rett til turneringen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* ---------- State 2: Link sent ---------- */}
|
|
{mode === "sent" && (
|
|
<div>
|
|
<div className="mb-5 flex size-14 items-center justify-center rounded-2xl bg-tee/20 text-tee-strong">
|
|
<MailIcon className="size-7" />
|
|
</div>
|
|
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Lenke sendt</h2>
|
|
<p className="mt-2 text-pretty text-base leading-relaxed text-clubhouse-ink">
|
|
Vi sendte en innloggingslenke til <span className="font-semibold break-all">{email}</span>.
|
|
</p>
|
|
<p className="mt-2 text-pretty text-[0.9375rem] leading-relaxed text-clubhouse-muted">
|
|
Åpne lenken på denne enheten for å logge inn. Det kan ta et minutt før den kommer frem.
|
|
</p>
|
|
|
|
{formError && <div className="mt-4">{<FormAlert>{formError}</FormAlert>}</div>}
|
|
|
|
{resentNote && !formError && (
|
|
<p role="status" className="mt-4 flex items-center gap-2 text-[0.9375rem] font-medium text-tee-strong">
|
|
<CheckIcon className="size-5 shrink-0" />
|
|
Lenken er sendt på nytt.
|
|
</p>
|
|
)}
|
|
|
|
<button type="button" onClick={handleResend} disabled={cooldown > 0 || loading} className={cn(primaryBtn, "mt-6")}>
|
|
{loading ? <Spinner className="size-5" /> : <MailIcon className="size-5" />}
|
|
{loading ? "Sender …" : cooldown > 0 ? `Send igjen om ${cooldown}s` : "Send lenken igjen"}
|
|
</button>
|
|
|
|
<div className="mt-6">
|
|
<button type="button" className={backBtn} onClick={() => switchMode("email", { clearEmail: true })}>
|
|
<ArrowLeftIcon className="size-5" />
|
|
Bruk en annen e-postadresse
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ---------- State 3: Password fallback ---------- */}
|
|
{mode === "password" && (
|
|
<form onSubmit={handlePasswordLogin} noValidate>
|
|
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Logg inn med passord</h2>
|
|
<p className="mt-1.5 text-pretty text-base leading-relaxed text-clubhouse-muted">
|
|
For deg som har satt et passord på kontoen din.
|
|
</p>
|
|
|
|
{formError && <div className="mt-5">{<FormAlert>{formError}</FormAlert>}</div>}
|
|
|
|
<div className="mt-5">
|
|
<label htmlFor="email-pw" className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
|
|
E-post
|
|
</label>
|
|
<input
|
|
id="email-pw"
|
|
name="email"
|
|
type="email"
|
|
inputMode="email"
|
|
autoComplete="email"
|
|
autoFocus
|
|
placeholder="deg@eksempel.no"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
className={cn(inputBase, "border-clubhouse-border")}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<label htmlFor={pwIds} className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
|
|
Passord
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
id={pwIds}
|
|
name="password"
|
|
type={showPassword ? "text" : "password"}
|
|
autoComplete="current-password"
|
|
placeholder="Passordet ditt"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className={cn(inputBase, "border-clubhouse-border pr-14")}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword((s) => !s)}
|
|
aria-label={showPassword ? "Skjul passord" : "Vis passord"}
|
|
aria-pressed={showPassword}
|
|
className={cn(
|
|
"absolute right-1 top-1/2 flex size-11 -translate-y-1/2 items-center justify-center rounded-lg text-clubhouse-muted transition-colors hover:text-clubhouse-ink",
|
|
focusRing,
|
|
)}
|
|
>
|
|
{showPassword ? <EyeOffIcon className="size-5" /> : <EyeIcon className="size-5" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" disabled={loading || !email.trim() || !password} className={cn(primaryBtn, "mt-6")}>
|
|
{loading ? <Spinner className="size-5" /> : <KeyIcon className="size-5" />}
|
|
{loading ? "Logger inn …" : "Logg inn"}
|
|
</button>
|
|
|
|
<div className="mt-6">
|
|
<button type="button" className={backBtn} onClick={() => switchMode("email")}>
|
|
<ArrowLeftIcon className="size-5" />
|
|
Bruk innloggingslenke på e-post i stedet
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* ---------- State 4: Join by code ---------- */}
|
|
{mode === "code" && (
|
|
<form onSubmit={handleJoin} noValidate>
|
|
<h2 className="text-balance text-xl font-bold text-clubhouse-ink">Bli med via kode</h2>
|
|
<p className="mt-1.5 text-pretty text-base leading-relaxed text-clubhouse-muted">
|
|
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>
|
|
|
|
{formError && <div className="mt-5">{<FormAlert>{formError}</FormAlert>}</div>}
|
|
|
|
<div className="mt-5">
|
|
<label htmlFor="invite" className="mb-1.5 block text-[0.9375rem] font-semibold text-clubhouse-ink">
|
|
Invitasjonskode
|
|
</label>
|
|
<input
|
|
id="invite"
|
|
name="invite"
|
|
type="text"
|
|
autoFocus
|
|
autoCapitalize="characters"
|
|
autoCorrect="off"
|
|
spellCheck={false}
|
|
enterKeyHint="go"
|
|
placeholder="F.EKS. RYDER-25"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
|
className={cn(inputBase, "border-clubhouse-border font-mono tracking-[0.15em] uppercase")}
|
|
/>
|
|
</div>
|
|
|
|
<button type="submit" disabled={loading || !code.trim()} className={cn(primaryBtn, "mt-6")}>
|
|
{loading ? <Spinner className="size-5" /> : <FlagIcon className="size-5" />}
|
|
{loading ? "Sjekker …" : "Gå til turnering"}
|
|
</button>
|
|
|
|
<div className="mt-6">
|
|
<button type="button" className={backBtn} onClick={() => switchMode("email")}>
|
|
<ArrowLeftIcon className="size-5" />
|
|
Tilbake til innlogging
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|