teecup/frontend/components/account-settings.tsx

1318 lines
49 KiB
TypeScript
Raw Normal View History

"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"
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import {
ArrowLeft,
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"
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
// 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 (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id} className="text-sm font-semibold">
Land
</Label>
<select
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
>
{COUNTRIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
)
}
// 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<HomeClubOption[]>([])
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative flex flex-col gap-1.5">
<Label htmlFor={id} className="text-sm font-semibold">
Hjemmeklubb
</Label>
<Input
id={id}
value={query}
onChange={(e) => {
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 && (
<ul className="absolute top-full z-20 mt-1 max-h-64 w-full overflow-auto rounded-xl border border-border bg-card shadow-md shadow-black/10">
{results.map((option) => (
<li key={option.slug}>
<button
type="button"
onClick={() => selectClub(option)}
className="flex min-h-11 w-full flex-col items-start justify-center px-3 py-1.5 text-left transition-colors hover:bg-accent/60"
>
<span className="text-sm font-semibold text-foreground">{option.name}</span>
{(option.city || option.county) && (
<span className="text-xs text-muted-foreground">
{[option.city, option.county].filter(Boolean).join(", ")}
</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
)
}
type Me = {
id: string
email: string
display_name: string
has_password: boolean
two_factor_method: "totp" | "email" | null
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
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
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
home_club: string | null
country: string | null
bio: string | null
mobile_country_code: string | null
mobile_number: string | null
bag_clubs: string[]
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
avatar_url: string | null
// 2026-07-22: alle obligatoriske profilfelt utfylt (bilde/beskrivelse
// teller ikke). Se AccountSettings sin bruk under.
profile_complete: boolean
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan, deploye og oppdatere .md-filer for deltaker-tilgang-runden Bygge og scratch-verifisere sekundær e-postadresse (migrasjon 017) Bygge HCP-historikk over tid Sekundær e-postadresse (det enkle tilfellet) er bygget og scratch-verifisert (20/20 sjekker), test_isolation.sql fortsatt 12/12. Hva er bygget: Migrasjon 017_secondary_email.sql — to nye tabeller (secondary_email_token, user_secondary_email), samme bevis-eierskap-token-mønster som ADR-032. Backend: POST /auth/secondary-email (send bekreftelseslenke), POST /auth/secondary-email/confirm (bekreft, ingen sesjon påkrevd), DELETE /auth/secondary-email/{id}. Viktigst: verify_magic_link og login_with_password løser nå en sekundær adresse til den eksisterende eierens konto i stedet for å opprette en ny, separat konto — dette er selve poenget med funksjonen. Frontend: ny seksjon "Andre e-postadresser" i /account (ikke dashbordet — se merknad under), /verify-email håndterer nå både e-postbytte og ny-sekundær-adresse via en ?kind=secondary-parameter. Én bevisst plasseringsavgjørelse jeg tok uten å spørre: du sa opprinnelig at hele multi-e-post-flyten burde skje "fra dashboard-siden". Jeg la likevel dette i /account, fordi jeg her kun bygger det enkle tilfellet (fri adresse) — konsistent med hvor e-postbytte (ADR-032) allerede ligger. Når/hvis den harde saken (ekte konto-sammenslåing, der data faktisk "dukker opp") bygges senere, er dashbordet trolig riktigere siden gevinsten vises der. Si fra hvis du vil at den skal flyttes allerede nå. Verifisert grundig: ny sekundær-adresse legges IKKE til før bekreftet; token kan ikke gjenbrukes; adresse som allerede er en annens hovedadresse ELLER en annens sekundæradresse avvises tydelig; innlogging (magic-link OG passord) via sekundæradressen løses korrekt til samme, eksisterende konto; en fremmed kan ikke slette andres sekundæradresse; og — kritisk — etter sletting oppretter en ny innlogging på den adressen en helt ny, separat konto (beviser fjerningen er reell). Ingen migrasjon kjørt mot ekte teecup_db ennå.
2026-07-22 06:14:31 +02:00
secondary_emails: { id: string; email: string }[]
}
export function AccountSettings() {
const router = useRouter()
const [me, setMe] = useState<Me | null>(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 (
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
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 (
<ProfileOnboarding
me={me}
onComplete={() => router.replace("/dashboard")}
/>
)
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
<Link
href="/dashboard"
aria-label="Tilbake til dashbord"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Konto</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{me.email}</h1>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
{settingUp2fa ? (
<div className="rounded-3xl border border-border bg-card p-6 shadow-sm shadow-black/5 sm:p-8">
<TwoFactorSetupForm
forced={false}
onSuccess={() => {
setSettingUp2fa(false)
void loadMe()
}}
/>
</div>
) : (
<div className="flex flex-col gap-6">
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
<ProfileSection me={me} onChanged={loadMe} />
<EmailSection email={me.email} />
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan, deploye og oppdatere .md-filer for deltaker-tilgang-runden Bygge og scratch-verifisere sekundær e-postadresse (migrasjon 017) Bygge HCP-historikk over tid Sekundær e-postadresse (det enkle tilfellet) er bygget og scratch-verifisert (20/20 sjekker), test_isolation.sql fortsatt 12/12. Hva er bygget: Migrasjon 017_secondary_email.sql — to nye tabeller (secondary_email_token, user_secondary_email), samme bevis-eierskap-token-mønster som ADR-032. Backend: POST /auth/secondary-email (send bekreftelseslenke), POST /auth/secondary-email/confirm (bekreft, ingen sesjon påkrevd), DELETE /auth/secondary-email/{id}. Viktigst: verify_magic_link og login_with_password løser nå en sekundær adresse til den eksisterende eierens konto i stedet for å opprette en ny, separat konto — dette er selve poenget med funksjonen. Frontend: ny seksjon "Andre e-postadresser" i /account (ikke dashbordet — se merknad under), /verify-email håndterer nå både e-postbytte og ny-sekundær-adresse via en ?kind=secondary-parameter. Én bevisst plasseringsavgjørelse jeg tok uten å spørre: du sa opprinnelig at hele multi-e-post-flyten burde skje "fra dashboard-siden". Jeg la likevel dette i /account, fordi jeg her kun bygger det enkle tilfellet (fri adresse) — konsistent med hvor e-postbytte (ADR-032) allerede ligger. Når/hvis den harde saken (ekte konto-sammenslåing, der data faktisk "dukker opp") bygges senere, er dashbordet trolig riktigere siden gevinsten vises der. Si fra hvis du vil at den skal flyttes allerede nå. Verifisert grundig: ny sekundær-adresse legges IKKE til før bekreftet; token kan ikke gjenbrukes; adresse som allerede er en annens hovedadresse ELLER en annens sekundæradresse avvises tydelig; innlogging (magic-link OG passord) via sekundæradressen løses korrekt til samme, eksisterende konto; en fremmed kan ikke slette andres sekundæradresse; og — kritisk — etter sletting oppretter en ny innlogging på den adressen en helt ny, separat konto (beviser fjerningen er reell). Ingen migrasjon kjørt mot ekte teecup_db ennå.
2026-07-22 06:14:31 +02:00
<SecondaryEmailSection secondaryEmails={me.secondary_emails} onChanged={loadMe} />
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<ShieldCheck aria-hidden="true" className="size-5 text-primary" />
</div>
<h2 className="text-base font-bold text-foreground">Topartsautentisering (2FA)</h2>
</div>
{me.two_factor_method ? (
<>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Aktivert med{" "}
<span className="font-semibold text-foreground">
{me.two_factor_method === "totp" ? "autentisator-app" : "engangskode på e-post"}
</span>
.
</p>
<Button
type="button"
variant="outline"
onClick={handleDisable2fa}
className="h-12 w-fit rounded-2xl font-semibold text-destructive hover:text-destructive"
>
<ShieldOff aria-hidden="true" className="size-4" />
Slå av 2FA
</Button>
</>
) : (
<>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ikke aktivert. Anbefales for organisasjonseiere/administratorer -- da vil du
bli bedt om å sette det opp ved neste innlogging uansett.
</p>
<Button
type="button"
onClick={() => setSettingUp2fa(true)}
className="h-12 w-fit rounded-2xl font-semibold"
>
<KeyRound aria-hidden="true" className="size-4" />
Sett opp 2FA
</Button>
</>
)}
</section>
</div>
)}
</main>
</div>
)
}
// --- 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<string | null>(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 (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center justify-between gap-4 px-5 py-4">
<Wordmark compact />
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<LogOut aria-hidden="true" className="size-4" />
Logg ut
</button>
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-8 sm:py-10">
<div className="flex flex-col gap-2 pb-6 text-center sm:text-left">
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
Fullfør profilen din
</h1>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
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».
</p>
</div>
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-first-name" className="text-sm font-semibold">
Fornavn
</Label>
<Input
id="ob-first-name"
autoFocus
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-last-name" className="text-sm font-semibold">
Etternavn
</Label>
<Input
id="ob-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-birth-date" className="text-sm font-semibold">
Fødselsdato
</Label>
<Input
id="ob-birth-date"
type="date"
value={birthDate}
onChange={(e) => setBirthDate(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-gender" className="text-sm font-semibold">
Kjønn
</Label>
<select
id="ob-gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
>
<option value="">Velg </option>
<option value="f">Dame</option>
<option value="m">Herre</option>
<option value="x">Annet</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="ob-hcp" className="text-sm font-semibold">
HCP
</Label>
<Input
id="ob-hcp"
inputMode="decimal"
value={hcp}
onChange={(e) => setHcp(e.target.value)}
className="h-11 rounded-xl"
/>
<p className="text-xs text-muted-foreground">
Ferske spillere har 54 -- la stå om du ikke har en offisiell HCP ennå.
</p>
</div>
<CountryField id="ob-country" value={country} onChange={setCountry} />
<HomeClubField id="ob-home-club" value={homeClub} onChange={setHomeClub} />
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="ob-bio" className="text-sm font-semibold">
Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<textarea
id="ob-bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={2}
placeholder="F.eks. Golf nut, running teeoff.no"
className="rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground outline-none"
/>
</div>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<Button type="submit" disabled={!valid || saving} className="h-12 w-fit rounded-xl font-semibold">
{saving ? "Lagrer …" : "Fortsett"}
</Button>
</form>
</main>
</div>
)
}
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
// --- Personlig profil (ADR-031) ---------------------------------------------
function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
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 ?? "")
const [hcp, setHcp] = useState(me.handicap_index === null ? "" : String(me.handicap_index))
const [country, setCountry] = useState(me.country ?? COUNTRIES[0])
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
const [homeClub, setHomeClub] = useState(me.home_club ?? "")
const [bio, setBio] = useState(me.bio ?? "")
const [mobileCountryCode, setMobileCountryCode] = useState(me.mobile_country_code ?? "+47")
const [mobileNumber, setMobileNumber] = useState(me.mobile_number ?? "")
const [bagClubs, setBagClubs] = useState<string[]>(me.bag_clubs)
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
const [saving, setSaving] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setError(null)
setSuccess(false)
try {
const res = await fetch("/auth/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
first_name: firstName.trim() === "" ? null : firstName.trim(),
last_name: lastName.trim() === "" ? null : lastName.trim(),
birth_date: birthDate === "" ? null : birthDate,
gender: gender === "" ? null : gender,
handicap_index: hcp.trim() === "" ? null : Number(hcp.replace(",", ".")),
home_club: homeClub.trim() === "" ? null : homeClub.trim(),
country: country.trim() === "" ? null : country.trim(),
bio: bio.trim() === "" ? null : bio.trim(),
mobile_country_code: mobileNumber.trim() === "" ? null : mobileCountryCode.trim(),
mobile_number: mobileNumber.trim() === "" ? null : mobileNumber.trim(),
bag_clubs: bagClubs,
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å lagre profilen.")
}
setSuccess(true)
onChanged()
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSaving(false)
}
}
async function handleAvatarSelected(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploadingAvatar(true)
setError(null)
try {
const formData = new FormData()
formData.append("file", file)
const res = await fetch("/auth/profile/avatar", {
method: "POST",
credentials: "include",
body: formData,
})
if (!res.ok) throw new Error("Klarte ikke å laste opp profilbildet.")
onChanged()
} catch (err) {
setError(err instanceof Error ? err.message : "Klarte ikke å laste opp profilbildet.")
} finally {
setUploadingAvatar(false)
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
async function handleRemoveAvatar() {
setUploadingAvatar(true)
try {
const res = await fetch("/auth/profile/avatar", { method: "DELETE", credentials: "include" })
if (res.ok) onChanged()
} finally {
setUploadingAvatar(false)
}
}
return (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<User aria-hidden="true" className="size-5 text-primary" />
</div>
<h2 className="text-base font-bold text-foreground">Personlig profil</h2>
</div>
<div className="flex items-center gap-4">
<div className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full bg-secondary">
{me.avatar_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={me.avatar_url} alt="" className="size-full object-cover" />
) : (
<User aria-hidden="true" className="size-7 text-muted-foreground" />
)}
</div>
<div className="flex flex-col gap-1.5">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={handleAvatarSelected}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingAvatar}
onClick={() => fileInputRef.current?.click()}
className="h-9 rounded-xl font-semibold"
>
<Camera aria-hidden="true" className="size-4" />
{me.avatar_url ? "Bytt bilde" : "Last opp bilde"}
</Button>
{me.avatar_url && (
<button
type="button"
onClick={handleRemoveAvatar}
disabled={uploadingAvatar}
className="inline-flex w-fit items-center gap-1 text-xs font-semibold text-muted-foreground transition-colors hover:text-destructive"
>
<X aria-hidden="true" className="size-3" />
Fjern bilde
</button>
)}
</div>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="first-name" className="text-sm font-semibold">
Fornavn
</Label>
<Input
id="first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="last-name" className="text-sm font-semibold">
Etternavn
</Label>
<Input
id="last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="birth-date" className="text-sm font-semibold">
Fødselsdato
</Label>
<Input
id="birth-date"
type="date"
value={birthDate}
onChange={(e) => setBirthDate(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="gender" className="text-sm font-semibold">
Kjønn
</Label>
<select
id="gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
>
<option value="">Ikke satt</option>
<option value="f">Dame</option>
<option value="m">Herre</option>
<option value="x">Annet</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="hcp" className="text-sm font-semibold">
HCP
</Label>
<Input
id="hcp"
inputMode="decimal"
value={hcp}
onChange={(e) => setHcp(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<CountryField id="country" value={country} onChange={setCountry} />
<HomeClubField id="home-club" value={homeClub} onChange={setHomeClub} />
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="bio" className="text-sm font-semibold">
Beskrivelse <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={2}
placeholder="F.eks. Golf nut, running teeoff.no"
className="rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground outline-none"
/>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="mobile-number" className="text-sm font-semibold">
Mobil
</Label>
<div className="flex gap-2">
<Input
id="mobile-country-code"
aria-label="Landsnummer"
value={mobileCountryCode}
onChange={(e) => setMobileCountryCode(e.target.value)}
placeholder="+47"
className="h-11 w-20 shrink-0 rounded-xl text-center"
/>
<Input
id="mobile-number"
type="tel"
value={mobileNumber}
onChange={(e) => setMobileNumber(e.target.value)}
placeholder="912 34 567"
className="h-11 flex-1 rounded-xl"
/>
</div>
</div>
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
</div>
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2">
<Label className="text-sm font-semibold">Køllebag</Label>
<span className="text-xs font-medium text-muted-foreground">
{bagClubs.length}/{MAX_BAG_CLUBS} valgt
</span>
</div>
<p className="text-xs leading-relaxed text-muted-foreground text-pretty">
Velg køllene du spiller med (maks {MAX_BAG_CLUBS}). Disse blir tilgjengelige som
valg når du registrerer hvilken kølle du slo ut med i en runde.
</p>
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
{BAG_CLUBS.map((club) => {
const checked = bagClubs.includes(club)
const disabled = !checked && bagClubs.length >= MAX_BAG_CLUBS
return (
<label
key={club}
className={`flex min-h-11 items-center gap-2 rounded-xl border px-3 py-2 text-sm font-medium ${
checked ? "border-primary bg-primary/10 text-foreground" : "border-border text-foreground"
} ${disabled ? "opacity-50" : "cursor-pointer"}`}
>
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) =>
setBagClubs((prev) =>
e.target.checked ? [...prev, club] : prev.filter((c) => c !== club),
)
}
className="size-4 shrink-0 rounded border-border"
/>
{club}
</label>
)
})}
</div>
</div>
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{success && <p className="text-sm font-medium text-primary">Profilen er oppdatert.</p>}
<Button type="submit" disabled={saving} className="h-11 w-fit rounded-xl font-semibold">
{saving ? "Lagrer …" : "Lagre profil"}
</Button>
</form>
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan, deploye og oppdatere .md-filer for deltaker-tilgang-runden Bygge og scratch-verifisere sekundær e-postadresse (migrasjon 017) Designe, bygge og scratch-verifisere HCP-historikk (migrasjon 018) HCP-historikk er bygget og scratch-verifisert (18/18 sjekker, test_isolation.sql fortsatt 12/12). Hva er bygget: Migrasjon 018_handicap_history.sql — ny append-only-tabell handicap_history, kun for den personlige profilens HCP (ikke lagenes frosne handicap_index_snapshot, som er urørt, ADR-007). Backend: PATCH /auth/profile logger nå automatisk en ny rad når HCP faktisk endres til en tallverdi — ikke ved ren nullstilling, og ikke ved gjentatt lagring av samme verdi (sjekket eksplisitt mot forrige verdi først). Ny GET /auth/profile/handicap-history. Frontend: en «Vis HCP-historikk»-lenke i /account sin profilseksjon, viser dato + verdi-liste, oppdateres automatisk etter lagring. Verifisert grundig: ingen duplikat ved gjentatt lagring av uendret verdi, korrekt logging ved faktisk endring, ingen logg ved nullstilling, ny logg ved gjeninnsetting etter nullstilling, kronologisk rekkefølge riktig, og full isolasjon mellom to brukeres historikk.
2026-07-22 06:31:11 +02:00
<ActualHandicapCard me={me} onChanged={onChanged} />
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan, deploye og oppdatere .md-filer for deltaker-tilgang-runden Bygge og scratch-verifisere sekundær e-postadresse (migrasjon 017) Designe, bygge og scratch-verifisere HCP-historikk (migrasjon 018) HCP-historikk er bygget og scratch-verifisert (18/18 sjekker, test_isolation.sql fortsatt 12/12). Hva er bygget: Migrasjon 018_handicap_history.sql — ny append-only-tabell handicap_history, kun for den personlige profilens HCP (ikke lagenes frosne handicap_index_snapshot, som er urørt, ADR-007). Backend: PATCH /auth/profile logger nå automatisk en ny rad når HCP faktisk endres til en tallverdi — ikke ved ren nullstilling, og ikke ved gjentatt lagring av samme verdi (sjekket eksplisitt mot forrige verdi først). Ny GET /auth/profile/handicap-history. Frontend: en «Vis HCP-historikk»-lenke i /account sin profilseksjon, viser dato + verdi-liste, oppdateres automatisk etter lagring. Verifisert grundig: ingen duplikat ved gjentatt lagring av uendret verdi, korrekt logging ved faktisk endring, ingen logg ved nullstilling, ny logg ved gjeninnsetting etter nullstilling, kronologisk rekkefølge riktig, og full isolasjon mellom to brukeres historikk.
2026-07-22 06:31:11 +02:00
<HandicapHistorySection reloadKey={me.handicap_index} />
Update Todos Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
2026-07-20 10:41:30 +02:00
</section>
)
}
// Faktisk (beregnet) HCP, ADR-038 -- WHS Handicap Index regnet automatisk
// fra frittstående runder (round.py sin _recompute_computed_handicap_index).
// Aldri direkte redigerbar -- kun "manuelt satt HCP" over er det. Denne
// kortet lar brukeren EKSPLISITT kopiere over verdien, ingen automatikk.
function ActualHandicapCard({ me, onChanged }: { me: Me; onChanged: () => void }) {
const [applying, setApplying] = useState(false)
const [error, setError] = useState<string | null>(null)
async function applyComputed() {
setApplying(true)
setError(null)
try {
const res = await fetch("/auth/profile/handicap/apply-computed", {
method: "POST",
credentials: "include",
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å overføre HCP.")
}
onChanged()
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setApplying(false)
}
}
return (
<div className="flex flex-col gap-2 rounded-xl border border-border bg-card p-4">
<span className="text-sm font-semibold text-foreground">Faktisk HCP (beregnet)</span>
{me.computed_handicap_index === null ? (
<p className="text-sm text-muted-foreground">
Ingen beregning ennå -- trengs minst 3 tellende frittstående runder.
</p>
) : (
<>
<p className="text-sm text-muted-foreground">
Basert 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"}
.
</p>
<div className="flex flex-wrap items-center gap-3">
<span className="text-2xl font-extrabold tabular-nums text-foreground">
{me.computed_handicap_index}
</span>
<Button
type="button"
variant="outline"
disabled={applying || me.computed_handicap_index === me.handicap_index}
onClick={applyComputed}
className="h-9 rounded-xl text-sm font-semibold"
>
{applying ? "Overfører …" : "Bruk som mitt HCP →"}
</Button>
</div>
</>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</div>
)
}
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan, deploye og oppdatere .md-filer for deltaker-tilgang-runden Bygge og scratch-verifisere sekundær e-postadresse (migrasjon 017) Designe, bygge og scratch-verifisere HCP-historikk (migrasjon 018) HCP-historikk er bygget og scratch-verifisert (18/18 sjekker, test_isolation.sql fortsatt 12/12). Hva er bygget: Migrasjon 018_handicap_history.sql — ny append-only-tabell handicap_history, kun for den personlige profilens HCP (ikke lagenes frosne handicap_index_snapshot, som er urørt, ADR-007). Backend: PATCH /auth/profile logger nå automatisk en ny rad når HCP faktisk endres til en tallverdi — ikke ved ren nullstilling, og ikke ved gjentatt lagring av samme verdi (sjekket eksplisitt mot forrige verdi først). Ny GET /auth/profile/handicap-history. Frontend: en «Vis HCP-historikk»-lenke i /account sin profilseksjon, viser dato + verdi-liste, oppdateres automatisk etter lagring. Verifisert grundig: ingen duplikat ved gjentatt lagring av uendret verdi, korrekt logging ved faktisk endring, ingen logg ved nullstilling, ny logg ved gjeninnsetting etter nullstilling, kronologisk rekkefølge riktig, og full isolasjon mellom to brukeres historikk.
2026-07-22 06:31:11 +02:00
// 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)
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan, deploye og oppdatere .md-filer for deltaker-tilgang-runden Bygge og scratch-verifisere sekundær e-postadresse (migrasjon 017) Designe, bygge og scratch-verifisere HCP-historikk (migrasjon 018) HCP-historikk er bygget og scratch-verifisert (18/18 sjekker, test_isolation.sql fortsatt 12/12). Hva er bygget: Migrasjon 018_handicap_history.sql — ny append-only-tabell handicap_history, kun for den personlige profilens HCP (ikke lagenes frosne handicap_index_snapshot, som er urørt, ADR-007). Backend: PATCH /auth/profile logger nå automatisk en ny rad når HCP faktisk endres til en tallverdi — ikke ved ren nullstilling, og ikke ved gjentatt lagring av samme verdi (sjekket eksplisitt mot forrige verdi først). Ny GET /auth/profile/handicap-history. Frontend: en «Vis HCP-historikk»-lenke i /account sin profilseksjon, viser dato + verdi-liste, oppdateres automatisk etter lagring. Verifisert grundig: ingen duplikat ved gjentatt lagring av uendret verdi, korrekt logging ved faktisk endring, ingen logg ved nullstilling, ny logg ved gjeninnsetting etter nullstilling, kronologisk rekkefølge riktig, og full isolasjon mellom to brukeres historikk.
2026-07-22 06:31:11 +02:00
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 (
<div className="border-t border-border pt-4">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
{open ? "Skjul HCP-historikk" : "Vis HCP-historikk"}
</button>
{open && (
<div className="mt-3">
{entries === null ? (
<p className="text-sm text-muted-foreground">Laster </p>
) : entries.length === 0 ? (
<p className="text-sm text-muted-foreground text-pretty">
Ingen historikk ennå registreres automatisk neste gang du endrer HCP.
</p>
) : (
<ul className="flex flex-col gap-1.5">
{[...entries].reverse().map((entry, i) => (
<li
key={`${entry.recorded_at}-${i}`}
className="flex items-center justify-between gap-3 text-sm"
>
<span className="text-muted-foreground">
{new Date(entry.recorded_at).toLocaleDateString("no-NO", {
day: "numeric",
month: "short",
year: "numeric",
})}
</span>
<span className="flex items-center gap-2">
<span className="font-semibold tabular-nums text-foreground">{entry.handicap_index}</span>
<span className="text-xs font-medium text-muted-foreground">
{entry.source === "computed" ? "beregnet" : "manuelt"}
</span>
</span>
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan, deploye og oppdatere .md-filer for deltaker-tilgang-runden Bygge og scratch-verifisere sekundær e-postadresse (migrasjon 017) Designe, bygge og scratch-verifisere HCP-historikk (migrasjon 018) HCP-historikk er bygget og scratch-verifisert (18/18 sjekker, test_isolation.sql fortsatt 12/12). Hva er bygget: Migrasjon 018_handicap_history.sql — ny append-only-tabell handicap_history, kun for den personlige profilens HCP (ikke lagenes frosne handicap_index_snapshot, som er urørt, ADR-007). Backend: PATCH /auth/profile logger nå automatisk en ny rad når HCP faktisk endres til en tallverdi — ikke ved ren nullstilling, og ikke ved gjentatt lagring av samme verdi (sjekket eksplisitt mot forrige verdi først). Ny GET /auth/profile/handicap-history. Frontend: en «Vis HCP-historikk»-lenke i /account sin profilseksjon, viser dato + verdi-liste, oppdateres automatisk etter lagring. Verifisert grundig: ingen duplikat ved gjentatt lagring av uendret verdi, korrekt logging ved faktisk endring, ingen logg ved nullstilling, ny logg ved gjeninnsetting etter nullstilling, kronologisk rekkefølge riktig, og full isolasjon mellom to brukeres historikk.
2026-07-22 06:31:11 +02:00
</li>
))}
</ul>
)}
</div>
)}
</div>
)
}
// --- 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<string | null>(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 (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<Mail aria-hidden="true" className="size-5 text-primary" />
</div>
<h2 className="text-base font-bold text-foreground">E-post</h2>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Dette er identifikatoren du logger inn med: <span className="font-semibold text-foreground">{email}</span>
</p>
{sent ? (
<p className="text-sm font-medium text-primary text-pretty">
Sjekk innboksen til {newEmail.trim()} åpne lenken der for å fullføre byttet. Adressen
endres ikke før den er bekreftet.
</p>
) : editing ? (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="new-email" className="text-sm font-semibold">
Ny e-postadresse
</Label>
<Input
id="new-email"
type="email"
autoFocus
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
className="h-12 rounded-xl"
/>
</div>
<div className="flex gap-2">
<Button
type="submit"
disabled={submitting || newEmail.trim() === ""}
className="h-12 shrink-0 rounded-xl font-semibold"
>
{submitting ? "Sender …" : "Send bekreftelse"}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setEditing(false)}
className="h-12 shrink-0 rounded-xl font-semibold"
>
Avbryt
</Button>
</div>
</form>
) : (
<Button
type="button"
variant="outline"
onClick={() => setEditing(true)}
className="h-11 w-fit rounded-xl font-semibold"
>
Endre e-post
</Button>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</section>
)
}
Update Todos Flytte is_participant-logikk til team_authz.py (unngå sirkulær import) Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup Widen tournaments.py: list_sessions/list_teams/concede_tournament Widen courses.py: list_holes Widen messaging.py: team chat REST-endepunkter (list/send/delete) Legge til my_session_id/my_match_id i /auth/me sin my_tournaments Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder» Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller) Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag Presentere plan, deploye og oppdatere .md-filer for deltaker-tilgang-runden Bygge og scratch-verifisere sekundær e-postadresse (migrasjon 017) Bygge HCP-historikk over tid Sekundær e-postadresse (det enkle tilfellet) er bygget og scratch-verifisert (20/20 sjekker), test_isolation.sql fortsatt 12/12. Hva er bygget: Migrasjon 017_secondary_email.sql — to nye tabeller (secondary_email_token, user_secondary_email), samme bevis-eierskap-token-mønster som ADR-032. Backend: POST /auth/secondary-email (send bekreftelseslenke), POST /auth/secondary-email/confirm (bekreft, ingen sesjon påkrevd), DELETE /auth/secondary-email/{id}. Viktigst: verify_magic_link og login_with_password løser nå en sekundær adresse til den eksisterende eierens konto i stedet for å opprette en ny, separat konto — dette er selve poenget med funksjonen. Frontend: ny seksjon "Andre e-postadresser" i /account (ikke dashbordet — se merknad under), /verify-email håndterer nå både e-postbytte og ny-sekundær-adresse via en ?kind=secondary-parameter. Én bevisst plasseringsavgjørelse jeg tok uten å spørre: du sa opprinnelig at hele multi-e-post-flyten burde skje "fra dashboard-siden". Jeg la likevel dette i /account, fordi jeg her kun bygger det enkle tilfellet (fri adresse) — konsistent med hvor e-postbytte (ADR-032) allerede ligger. Når/hvis den harde saken (ekte konto-sammenslåing, der data faktisk "dukker opp") bygges senere, er dashbordet trolig riktigere siden gevinsten vises der. Si fra hvis du vil at den skal flyttes allerede nå. Verifisert grundig: ny sekundær-adresse legges IKKE til før bekreftet; token kan ikke gjenbrukes; adresse som allerede er en annens hovedadresse ELLER en annens sekundæradresse avvises tydelig; innlogging (magic-link OG passord) via sekundæradressen løses korrekt til samme, eksisterende konto; en fremmed kan ikke slette andres sekundæradresse; og — kritisk — etter sletting oppretter en ny innlogging på den adressen en helt ny, separat konto (beviser fjerningen er reell). Ingen migrasjon kjørt mot ekte teecup_db ennå.
2026-07-22 06:14:31 +02:00
// É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<string | null>(null)
const [sent, setSent] = useState(false)
const [removingId, setRemovingId] = useState<string | null>(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 (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<Mail aria-hidden="true" className="size-5 text-primary" />
</div>
<h2 className="text-base font-bold text-foreground">Andre e-postadresser</h2>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Har du fått en turneringsinvitasjon en annen adresse enn {""}
{"hovedadressen din"}? Legg den til her, kan du logge inn med begge og turneringer/
data knyttet til den andre adressen dukker opp kontoen din.
</p>
{secondaryEmails.length > 0 && (
<ul className="flex flex-col gap-2">
{secondaryEmails.map((se) => (
<li
key={se.id}
className="flex items-center justify-between gap-2 rounded-xl border border-border bg-background px-3 py-2.5"
>
<span className="truncate text-sm font-semibold text-foreground">{se.email}</span>
<Button
type="button"
variant="ghost"
size="icon"
disabled={removingId === se.id}
onClick={() => handleRemove(se.id)}
className="size-8 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
aria-label={`Fjern ${se.email}`}
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
</li>
))}
</ul>
)}
{sent ? (
<p className="text-sm font-medium text-primary text-pretty">
Sjekk innboksen til {newEmail.trim()} åpne lenken der for å bekrefte at du eier
adressen. Ingenting legges til før den er bekreftet.
</p>
) : adding ? (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="secondary-email" className="text-sm font-semibold">
Ny adresse
</Label>
<Input
id="secondary-email"
type="email"
autoFocus
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
className="h-12 rounded-xl"
/>
</div>
<div className="flex gap-2">
<Button
type="submit"
disabled={submitting || newEmail.trim() === ""}
className="h-12 shrink-0 rounded-xl font-semibold"
>
{submitting ? "Sender …" : "Send bekreftelse"}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setAdding(false)}
className="h-12 shrink-0 rounded-xl font-semibold"
>
Avbryt
</Button>
</div>
</form>
) : (
<Button
type="button"
variant="outline"
onClick={() => setAdding(true)}
className="h-11 w-fit rounded-xl font-semibold"
>
<Plus aria-hidden="true" className="size-4" />
Legg til adresse
</Button>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</section>
)
}
function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) {
const [password, setPassword] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(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 (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<Lock aria-hidden="true" className="size-5 text-primary" />
</div>
<h2 className="text-base font-bold text-foreground">Passord</h2>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{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."}
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="new-password" className="text-sm font-semibold">
{hasPassword ? "Nytt passord" : "Sett et passord"}
</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
placeholder="Minst 8 tegn"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-12 rounded-xl"
/>
</div>
<Button
type="submit"
disabled={password.length < 8 || submitting}
className="h-12 shrink-0 rounded-xl font-semibold"
>
{submitting ? "Lagrer …" : hasPassword ? "Bytt passord" : "Sett passord"}
</Button>
</form>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{success && <p className="text-sm font-medium text-primary">Passordet er oppdatert.</p>}
{hasPassword && (
<button
type="button"
onClick={handleRemove}
className="w-fit text-sm font-semibold text-muted-foreground transition-colors hover:text-destructive"
>
Fjern passordet
</button>
)}
</section>
)
}