"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,
BellRing,
Camera,
Copy,
Flag,
KeyRound,
Lock,
LogOut,
Mail,
Pencil,
Plus,
ShieldCheck,
ShieldOff,
Trash2,
User,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { getExistingSubscription, isPushSupported, subscribeToPush, unsubscribeFromPush } from "@/lib/push-subscribe"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Wordmark } from "@/components/wordmark"
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
import { CourseTemplateEditor, type CourseTemplateValues } from "@/components/course-template-editor"
// 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 (
)
}
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 (
Ingen beregning ennå -- trengs minst 3 tellende frittstående runder.
) : (
<>
Basert på dine siste tellende runder (WHS Rule 5.2), sist beregnet{" "}
{me.computed_handicap_index_updated_at
? new Date(me.computed_handicap_index_updated_at).toLocaleDateString("no-NO", {
day: "numeric",
month: "short",
year: "numeric",
})
: "nylig"}
.
{me.computed_handicap_index}
>
)}
{error &&
{error}
}
)
}
// HCP-historikk (ADR-031 sitt "naturlig neste steg"-punkt): en append-only
// logg bygget opp av selve PATCH-endepunktet (app/routers/auth.py) hver
// gang HCP-feltet faktisk endres til en tallverdi. `reloadKey` (gjeldende
// HCP) sørger for at listen hentes på nytt rett etter en lagring, uten en
// egen refetch-prop å tre gjennom fra ProfileSection.
function HandicapHistorySection({ reloadKey }: { reloadKey: number | null }) {
const [open, setOpen] = useState(false)
const [entries, setEntries] = useState<
{ handicap_index: number; recorded_at: string; source: "manual" | "computed" }[] | null
>(null)
useEffect(() => {
if (!open) return
let cancelled = false
fetch("/auth/profile/handicap-history", { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data) => {
if (!cancelled) setEntries(data)
})
.catch(() => {
if (!cancelled) setEntries([])
})
return () => {
cancelled = true
}
}, [open, reloadKey])
return (
{open && (
{entries === null ? (
Laster …
) : entries.length === 0 ? (
Ingen historikk ennå — registreres automatisk neste gang du endrer HCP.
)
}
// --- E-post (identifikatoren) -----------------------------------------------
// Bevisst IKKE en del av ProfileSection sin vanlige PATCH -- e-post er
// innloggings-identifikatoren, endring krever at den NYE adressen beviser
// eierskap først (se app/routers/auth.py sin request_email_change/
// confirm_email_change).
function EmailSection({ email }: { email: string }) {
const [editing, setEditing] = useState(false)
const [newEmail, setNewEmail] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const [sent, setSent] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await fetch("/auth/profile/email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ new_email: newEmail.trim() }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å sende bekreftelseslenken.")
}
setSent(true)
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSubmitting(false)
}
}
return (
E-post
Dette er identifikatoren du logger inn med: {email}
{sent ? (
Sjekk innboksen til {newEmail.trim()} — åpne lenken der for å fullføre byttet. Adressen
endres ikke før den er bekreftet.
) : editing ? (
) : (
)}
{error &&
{error}
}
)
}
// Én person, flere e-postadresser (FEATURE_BACKLOG.md) -- kun det enkle
// tilfellet: en FRI, ukrevd adresse legges til og verifiseres, og kan
// deretter brukes til innlogging i tillegg til hovedadressen. Ekte
// konto-sammenslåing (adressen tilhører allerede en annen konto) er
// bevisst IKKE støttet -- backend avviser da med en tydelig 409 DUPLICATE.
function SecondaryEmailSection({
secondaryEmails,
onChanged,
}: {
secondaryEmails: { id: string; email: string }[]
onChanged: () => void
}) {
const [adding, setAdding] = useState(false)
const [newEmail, setNewEmail] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const [sent, setSent] = useState(false)
const [removingId, setRemovingId] = useState(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await fetch("/auth/secondary-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: newEmail.trim() }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å sende bekreftelseslenken.")
}
setSent(true)
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSubmitting(false)
}
}
async function handleRemove(id: string) {
setRemovingId(id)
try {
const res = await fetch(`/auth/secondary-email/${id}`, { method: "DELETE", credentials: "include" })
if (res.ok || res.status === 404) onChanged()
} finally {
setRemovingId(null)
}
}
return (
Andre e-postadresser
Har du fått en turneringsinvitasjon på en annen adresse enn {""}
{"hovedadressen din"}? Legg den til her, så kan du logge inn med begge — og turneringer/
data knyttet til den andre adressen dukker opp på kontoen din.
{secondaryEmails.length > 0 && (
{secondaryEmails.map((se) => (
{se.email}
))}
)}
{sent ? (
Sjekk innboksen til {newEmail.trim()} — åpne lenken der for å bekrefte at du eier
adressen. Ingenting legges til før den er bekreftet.
) : adding ? (
) : (
)}
{error &&
{error}
}
)
}
function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) {
const [password, setPassword] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (password.length < 8 || submitting) return
setSubmitting(true)
setError(null)
setSuccess(false)
try {
const res = await fetch("/auth/set-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ password }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å sette passordet.")
}
setPassword("")
setSuccess(true)
onChanged()
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSubmitting(false)
}
}
async function handleRemove() {
if (!confirm("Fjerne passordet? Du kan fortsatt logge inn med magic-link.")) return
const res = await fetch("/auth/remove-password", { method: "POST", credentials: "include" })
if (res.ok) onChanged()
}
return (
Passord
{hasPassword
? "Du har et passord satt. Du kan fortsatt logge inn med magic-link når som helst."
: "Ikke satt -- du logger i dag kun inn med magic-link. Passord er et valgfritt tillegg, aldri påkrevd."}
{error &&
{error}
}
{success &&
Passordet er oppdatert.
}
{hasPassword && (
)}
)
}
// --- E-post-fallback for varsler (2026-07-28 oppfølging) --------------------
// Trygg standard: ingen typer valgt inntil brukeren selv velger inn --
// PUT /notifications/email-prefs er en FULL erstatning (samme mønster som
// venne-kategoriseringen), ikke en delvis PATCH.
const NOTIFICATION_TYPE_OPTIONS: { value: string; label: string; description: string }[] = [
{ value: "friend", label: "Venneforespørsler", description: "Noen sender deg en venneforespørsel, eller godtar din." },
{ value: "round", label: "Runder", description: "Du blir lagt til som medspiller, eller en venn starter en runde du kan følge." },
{ value: "result", label: "Resultater", description: "En runde du er koblet til blir fullført." },
{ value: "tournament", label: "Turneringer", description: "Reservert for fremtidige turnering-varsler." },
]
// Push-varsler til telefonens OS (Web Push/VAPID, 2026-07-28). Egen seksjon
// ved siden av e-post-varianten over -- selve abonnementet ER samtykket her
// (ulikt e-post, som trenger en egen per-type opt-in), så dette er kun en
// enkel av/på-bryter for DENNE enheten/nettleseren.
function PushNotificationSection() {
const [supported, setSupported] = useState(null)
const [subscribed, setSubscribed] = useState(false)
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState(null)
const [isIOS, setIsIOS] = useState(false)
const [isStandalone, setIsStandalone] = useState(false)
useEffect(() => {
setSupported(isPushSupported())
setIsIOS(/iphone|ipad|ipod/i.test(window.navigator.userAgent))
setIsStandalone(window.matchMedia("(display-mode: standalone)").matches)
void getExistingSubscription().then((sub) => setSubscribed(sub !== null))
}, [])
async function toggle() {
setBusy(true)
setMessage(null)
try {
if (subscribed) {
await unsubscribeFromPush()
setSubscribed(false)
setMessage("Push-varsler er slått av på denne enheten.")
} else {
const result = await subscribeToPush()
if (result.ok) {
setSubscribed(true)
setMessage("Push-varsler er slått på for denne enheten.")
} else if (result.reason === "denied") {
setMessage("Nettleseren din blokkerer varsler -- gi TeeCup tillatelse i nettleserinnstillingene.")
} else if (result.reason === "not-configured") {
setMessage("Push er ikke satt opp på serveren ennå.")
} else {
setMessage("Klarte ikke å slå på push-varsler. Prøv igjen.")
}
}
} finally {
setBusy(false)
}
}
return (
Push-varsler på denne enheten
Få varsler direkte fra telefonen/nettleseren din, selv når TeeCup ikke er åpen -- for eksempel
når noen sender deg en venneforespørsel eller en runde du følger blir ferdig.