"use client" // Kontoinnstillinger (ADR-021): sett/fjern passord (valgfritt tillegg til // magic-link), aktiver/deaktiver 2FA. Frivillig 2FA-oppsett herfra bruker // SAMME TwoFactorSetupForm som den tvungne flyten rett etter innlogging // (forced=false her -- kun tekst-copyen skiller de to). import type React from "react" import { useEffect, useRef, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { ArrowLeft, Bell, Camera, KeyRound, Lock, LogOut, Mail, Plus, ShieldCheck, ShieldOff, Trash2, User, X, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Wordmark } from "@/components/wordmark" import { TwoFactorSetupForm } from "@/components/two-factor-flow" // ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de // org-scopede `player`-radene organisatorer administrerer. // Kølle-bag (2026-07-24): fast, ikke-utvidbar liste -- MÅ matche // BAG_CLUBS i app/routers/auth.py nøyaktig (backend validerer strengt). const BAG_CLUBS = [ "Driver", "Mini-driver", "Driving iron", "3-wood", "4-wood", "5-wood", "7-wood", "9-wood", "2-hybrid", "3-hybrid", "4-hybrid", "5-hybrid", "1-iron", "2-iron", "3-iron", "4-iron", "5-iron", "6-iron", "7-iron", "8-iron", "9-iron", "Pitching wedge", "Gap wedge", "Sand wedge", "Lob wedge", "Ultra-lob wedge", "Chipper", "Putter", ] as const const MAX_BAG_CLUBS = 14 // Land+hjemmeklubb (2026-07-25): "Land" er klargjøring for fremtidig // flerspråklighet -- kun norske klubber finnes i teeoff ennå, så listen har // bevisst kun ett valg foreløpig. Flere land legges til her når teeoff (eller // en fremtidig tilsvarende kilde) faktisk har data for dem -- INGEN // backend-endring trengs da, kun denne listen utvides. const COUNTRIES = ["Norge"] as const function CountryField({ id, value, onChange }: { id: string; value: string; onChange: (v: string) => void }) { return (
) } // Hjemmeklubb (2026-07-25): søkbar liste mot teeoffs klubbregister -- // gjenbruker `/rounds/official-search` (allerede org-uavhengig, tilgjengelig // for enhver innlogget bruker, ADR-033), samme mønster som bane-søket ved ny // runde. Teeoff filtrerer allerede bort upubliserte/nedlagte anlegg // server-side (`is_published`), så ingen egen filtrering trengs her. type HomeClubOption = { slug: string; name: string; city: string | null; county: string | null } function HomeClubField({ id, value, onChange }: { id: string; value: string; onChange: (v: string) => void }) { const [query, setQuery] = useState(value) const [results, setResults] = useState([]) const [open, setOpen] = useState(false) const containerRef = useRef(null) useEffect(() => { if (!open) return let cancelled = false const timer = setTimeout(async () => { try { const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, { credentials: "include", }) if (res.ok && !cancelled) setResults(await res.json()) } catch { // Stille -- listen blir bare uendret, ingen kritisk feil å vise her. } }, 250) return () => { cancelled = true clearTimeout(timer) } }, [query, open]) useEffect(() => { function handleClickOutside(e: MouseEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) setOpen(false) } document.addEventListener("mousedown", handleClickOutside) return () => document.removeEventListener("mousedown", handleClickOutside) }, []) function selectClub(option: HomeClubOption) { onChange(option.name) setQuery(option.name) setOpen(false) } return (
{ setQuery(e.target.value) onChange(e.target.value) setOpen(true) }} onFocus={() => setOpen(true)} placeholder="Søk etter klubb …" autoComplete="off" className="h-11 rounded-xl" /> {open && results.length > 0 && (
    {results.map((option) => (
  • ))}
)}
) } type Me = { id: string email: string display_name: string has_password: boolean two_factor_method: "totp" | "email" | null first_name: string | null last_name: string | null birth_date: string | null gender: "m" | "f" | "x" | null handicap_index: number | null // Faktisk (beregnet) HCP, ADR-038 -- WHS Handicap Index regnet // automatisk fra frittstående runder. Aldri direkte redigerbar. computed_handicap_index: number | null computed_handicap_index_updated_at: string | null home_club: string | null country: string | null bio: string | null mobile_country_code: string | null mobile_number: string | null bag_clubs: string[] avatar_url: string | null // 2026-07-22: alle obligatoriske profilfelt utfylt (bilde/beskrivelse // teller ikke). Se AccountSettings sin bruk under. profile_complete: boolean secondary_emails: { id: string; email: string }[] } export function AccountSettings() { const router = useRouter() const [me, setMe] = useState(null) const [loading, setLoading] = useState(true) const [settingUp2fa, setSettingUp2fa] = useState(false) async function loadMe() { try { const res = await fetch("/auth/me", { credentials: "include" }) if (res.ok) setMe(await res.json()) } finally { setLoading(false) } } useEffect(() => { void loadMe() }, []) async function handleDisable2fa() { if (!confirm("Er du sikker på at du vil slå av topartsautentisering?")) return const res = await fetch("/auth/2fa/disable", { method: "POST", credentials: "include" }) if (res.ok) void loadMe() } if (loading) { return (
) } if (!me) return null // Obligatorisk profil-fullføring (2026-07-22): en ny/ufullstendig konto // ser KUN dette skjemaet -- ingen tilgang til resten av kontosidene eller // dashbordet før alle obligatoriske felt er utfylt. Se page.tsx og // Dashboard for de to inngangspunktene som sender brukeren hit. if (!me.profile_complete) { return ( router.replace("/dashboard")} /> ) } return (
{settingUp2fa ? (
{ setSettingUp2fa(false) void loadMe() }} />
) : (

Topartsautentisering (2FA)

{me.two_factor_method ? ( <>

Aktivert med{" "} {me.two_factor_method === "totp" ? "autentisator-app" : "engangskode på e-post"} .

) : ( <>

Ikke aktivert. Anbefales for organisasjonseiere/administratorer -- da vil du bli bedt om å sette det opp ved neste innlogging uansett.

)}
)}
) } // --- Obligatorisk profil-fullføring (2026-07-22) ---------------------------- // Vises i STEDET for resten av appen (dashbord, øvrige kontoseksjoner) helt // til alle obligatoriske felt er utfylt -- se AccountSettings sin // profile_complete-gren. Kun bilde og beskrivelse er valgfrie her. function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void }) { const router = useRouter() const [firstName, setFirstName] = useState(me.first_name ?? "") const [lastName, setLastName] = useState(me.last_name ?? "") const [birthDate, setBirthDate] = useState(me.birth_date ?? "") const [gender, setGender] = useState(me.gender ?? "") // WHS-maksimum (54) er riktig utgangspunkt for en spiller uten offisiell // HCP ennå -- forhåndsutfylt, ikke tomt, jf. brukerens eksplisitte ønske. const [hcp, setHcp] = useState(me.handicap_index === null ? "54" : String(me.handicap_index)) // Landfeltet er nå en nedtrekksliste (2026-07-25) -- default til det // eneste tilgjengelige valget, samme "forhåndsutfylt fremfor tomt"-prinsipp // som HCP-standarden 54 over. const [country, setCountry] = useState(me.country ?? COUNTRIES[0]) const [homeClub, setHomeClub] = useState(me.home_club ?? "") const [bio, setBio] = useState(me.bio ?? "") const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const valid = firstName.trim() !== "" && lastName.trim() !== "" && birthDate !== "" && gender !== "" && hcp.trim() !== "" && homeClub.trim() !== "" && country.trim() !== "" async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!valid || saving) return setSaving(true) setError(null) try { const res = await fetch("/auth/profile", { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ first_name: firstName.trim(), last_name: lastName.trim(), birth_date: birthDate, gender, handicap_index: Number(hcp.replace(",", ".")), home_club: homeClub.trim(), country: country.trim(), bio: bio.trim() === "" ? null : bio.trim(), }), }) if (!res.ok) { const body = await res.json().catch(() => null) throw new Error(body?.detail?.message ?? "Klarte ikke å lagre profilen.") } const updated: Me = await res.json() if (updated.profile_complete) { onComplete() } } catch (err) { setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.") } finally { setSaving(false) } } async function handleLogout() { try { await fetch("/auth/logout", { method: "POST", credentials: "include" }) } finally { router.replace("/") } } return (

Fullfør profilen din

Før du kan komme videre trenger vi noen opplysninger om deg. Alt utenom beskrivelse er obligatorisk. Du kan redigere alt dette senere under «Konto».

setFirstName(e.target.value)} className="h-11 rounded-xl" />
setLastName(e.target.value)} className="h-11 rounded-xl" />
setBirthDate(e.target.value)} className="h-11 rounded-xl" />
setHcp(e.target.value)} className="h-11 rounded-xl" />

Ferske spillere har 54 -- la stå om du ikke har en offisiell HCP ennå.