Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
"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"
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
import Link from "next/link"
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
import { useRouter } from "next/navigation"
|
|
|
|
|
import {
|
|
|
|
|
ArrowLeft,
|
2026-07-28 20:09:21 +02:00
|
|
|
Bell,
|
Update Todos
Bygg PWA-installasjonsoppfordring (Android beforeinstallprompt + iOS instruksjonsbanner)
Design og bygg flere flighter i én frittstående runde (løs gruppering, retning 1)
Design og bygg scramble/greensome-statistikk (valgt utslag per spiller)
Bygg push-varsler til telefonens OS (VAPID, push_subscription, service worker)
Scratch-verifiser backend for alle fire (27/27 sjekker gjort)
Browserverifiser alle fire i nettleser mot scratch-backend
Rull ut mot ekte systemer (migrasjoner + containere), med bekreftelse
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Alle fire er ferdigstilt, verifisert og live. Kort oppsummert:
PWA-installasjonsoppfordring — ekte beforeinstallprompt-fanging, Android-knapp + iOS-instruksjonsbanner, vist på dashbordet. Bekreftet at Chrome faktisk fyrte eventet i testøkten.
Flere flighter i én frittstående runde — løs gruppering av separate runder (delt UUID), "+ Legg til en flight til" gjenbruker hele opprett-flyten forhåndsutfylt, nytt kombinert leaderboard på tvers av flighter. Testet fra knappetrykk til ferdig runde, bekreftet i database.
Scramble/greensome: valgt utslag per spiller — nytt felt i hull-registreringen (helt valgfritt), "Utslag brukt"-oppsummering på statistikksiden. Bygget for frittstående runder; org-turneringer bevisst utenfor omfang denne runden (notert i backloggen).
Push-varsler til telefonens OS — Web Push/VAPID, ny av/på-bryter i kontoinnstillinger, dekker alle fire varseltyper automatisk. VAPID-nøkler generert og lagt i .env uten å vises i chatten. Én ærlig begrensning: ekte OS-varsel-levering med innvilget tillatelse kunne ikke testes i denne automatiserte nettleserøkten (tillatelsen var forhåndsblokkert av miljøet) — verdt å teste selv på en ekte enhet.
27/27 scratch-sjekker, full nettleser-gjennomgang uten konsollfeil, migrasjoner 035–037 kjørt mot ekte teecup_db, begge containere redeployet og verifisert, teeoff.no upåvirket. Alle tre .md-filene er oppdatert med full detalj.
2026-07-28 22:34:16 +02:00
|
|
|
BellRing,
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
Camera,
|
2026-08-04 11:34:28 +02:00
|
|
|
Copy,
|
|
|
|
|
Flag,
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
KeyRound,
|
|
|
|
|
Lock,
|
|
|
|
|
LogOut,
|
|
|
|
|
Mail,
|
2026-08-04 11:34:28 +02:00
|
|
|
Pencil,
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
Plus,
|
|
|
|
|
ShieldCheck,
|
|
|
|
|
ShieldOff,
|
|
|
|
|
Trash2,
|
|
|
|
|
User,
|
|
|
|
|
X,
|
|
|
|
|
} from "lucide-react"
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
import { Button } from "@/components/ui/button"
|
Update Todos
Bygg PWA-installasjonsoppfordring (Android beforeinstallprompt + iOS instruksjonsbanner)
Design og bygg flere flighter i én frittstående runde (løs gruppering, retning 1)
Design og bygg scramble/greensome-statistikk (valgt utslag per spiller)
Bygg push-varsler til telefonens OS (VAPID, push_subscription, service worker)
Scratch-verifiser backend for alle fire (27/27 sjekker gjort)
Browserverifiser alle fire i nettleser mot scratch-backend
Rull ut mot ekte systemer (migrasjoner + containere), med bekreftelse
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Alle fire er ferdigstilt, verifisert og live. Kort oppsummert:
PWA-installasjonsoppfordring — ekte beforeinstallprompt-fanging, Android-knapp + iOS-instruksjonsbanner, vist på dashbordet. Bekreftet at Chrome faktisk fyrte eventet i testøkten.
Flere flighter i én frittstående runde — løs gruppering av separate runder (delt UUID), "+ Legg til en flight til" gjenbruker hele opprett-flyten forhåndsutfylt, nytt kombinert leaderboard på tvers av flighter. Testet fra knappetrykk til ferdig runde, bekreftet i database.
Scramble/greensome: valgt utslag per spiller — nytt felt i hull-registreringen (helt valgfritt), "Utslag brukt"-oppsummering på statistikksiden. Bygget for frittstående runder; org-turneringer bevisst utenfor omfang denne runden (notert i backloggen).
Push-varsler til telefonens OS — Web Push/VAPID, ny av/på-bryter i kontoinnstillinger, dekker alle fire varseltyper automatisk. VAPID-nøkler generert og lagt i .env uten å vises i chatten. Én ærlig begrensning: ekte OS-varsel-levering med innvilget tillatelse kunne ikke testes i denne automatiserte nettleserøkten (tillatelsen var forhåndsblokkert av miljøet) — verdt å teste selv på en ekte enhet.
27/27 scratch-sjekker, full nettleser-gjennomgang uten konsollfeil, migrasjoner 035–037 kjørt mot ekte teecup_db, begge containere redeployet og verifisert, teeoff.no upåvirket. Alle tre .md-filene er oppdatert med full detalj.
2026-07-28 22:34:16 +02:00
|
|
|
import { getExistingSubscription, isPushSupported, subscribeToPush, unsubscribeFromPush } from "@/lib/push-subscribe"
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
import { Input } from "@/components/ui/input"
|
|
|
|
|
import { Label } from "@/components/ui/label"
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
import { Wordmark } from "@/components/wordmark"
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
|
2026-08-04 11:34:28 +02:00
|
|
|
import { CourseTemplateEditor, type CourseTemplateValues } from "@/components/course-template-editor"
|
2026-08-08 12:34:03 +02:00
|
|
|
import { BottomNav } from "@/components/teecup/bottom-nav"
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
|
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.
|
2026-07-24 06:27:35 +02:00
|
|
|
// 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
|
|
|
|
|
|
2026-07-25 06:29:09 +02:00
|
|
|
// 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
|
|
|
|
|
|
2026-08-10 07:00:11 +02:00
|
|
|
// Aldersgrense (2026-08-10, se ARCHITECTURE_DECISIONS.md): brukt av BÅDE
|
|
|
|
|
// ProfileOnboarding og ProfileSection for umiddelbar tilbakemelding -- den
|
|
|
|
|
// ekte, håndhevede grensen ligger server-side i PATCH /auth/profile.
|
|
|
|
|
// `dateStr` er "YYYY-MM-DD" (rå verdi fra en <input type="date">).
|
|
|
|
|
function computeAge(dateStr: string): number {
|
|
|
|
|
const birth = new Date(dateStr)
|
|
|
|
|
const today = new Date()
|
|
|
|
|
let age = today.getFullYear() - birth.getFullYear()
|
|
|
|
|
const hasHadBirthdayThisYear =
|
|
|
|
|
today.getMonth() > birth.getMonth() ||
|
|
|
|
|
(today.getMonth() === birth.getMonth() && today.getDate() >= birth.getDate())
|
|
|
|
|
if (!hasHadBirthdayThisYear) age -= 1
|
|
|
|
|
return age
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 06:29:09 +02:00
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
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
|
Alt er ferdig, verifisert og live. Oppsummert:
Hullet lukket: hele WHS-HCP-motoren fantes og var testet, men ble aldri kalt — score_differential ble lagret per runde, men ingen indeks ble noensinne regnet ut. Nå har du:
To atskilte tall: manuelt satt HCP (uendret bruk) og et nytt, automatisk beregnet «faktisk HCP» (beste 8-av-≤20 nyeste tellende runder, WHS Rule 5.2, med Low-HCP-cap).
«Bruk som mitt HCP»-knapp på /account for å eksplisitt overføre.
Eksklusjon per deltaker — hver innlogget spiller (ikke bare eieren) styrer selv om egen deltakelse skal telle, uansett fullført-status.
Selvdeklarert spilleform (slagspill/matchspill) — matchspill forhåndsforeslår (ikke tvinger) eksklusjon, begrunnet i WHS sin «most likely score»-regel som TeeCup ikke kan garantere presist.
Verifisert med 111/111 scratch-sjekker (inkl. håndregnet WHS-matte) og en ekte nettleser-gjennomgang av hele flyten. Migrasjon 030 kjørt mot ekte teecup_db, begge containere redeployet, alt grønt, teeoff.no upåvirket.
Gjenstår (dokumentert i CLAUDE.md/FEATURE_BACKLOG.md, ikke bygget nå): offline-kø for frittstående runder, Stableford, rundedeling/visibility, flere flighter i én runde.
2026-07-28 11:04:51 +02:00
|
|
|
// 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
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
country: string | null
|
|
|
|
|
bio: string | null
|
2026-07-20 11:32:10 +02:00
|
|
|
mobile_country_code: string | null
|
|
|
|
|
mobile_number: string | null
|
2026-07-24 06:27:35 +02:00
|
|
|
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
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
// 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 }[]
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function AccountSettings() {
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
const router = useRouter()
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
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
|
|
|
|
|
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
// 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")}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
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>
|
|
|
|
|
|
2026-08-07 22:02:56 +02:00
|
|
|
<main className="mx-auto w-full max-w-2xl flex-1 px-5 pb-24 pt-6 sm:pt-8">
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
{settingUp2fa ? (
|
2026-07-29 12:44:51 +02:00
|
|
|
<div className="rounded-3xl border border-border bg-card p-6 shadow-md shadow-black/8 sm:p-8">
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
<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} />
|
|
|
|
|
|
2026-07-20 11:32:10 +02:00
|
|
|
<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} />
|
|
|
|
|
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
|
|
|
|
|
|
2026-07-28 20:09:21 +02:00
|
|
|
<NotificationEmailPrefsSection />
|
|
|
|
|
|
Update Todos
Bygg PWA-installasjonsoppfordring (Android beforeinstallprompt + iOS instruksjonsbanner)
Design og bygg flere flighter i én frittstående runde (løs gruppering, retning 1)
Design og bygg scramble/greensome-statistikk (valgt utslag per spiller)
Bygg push-varsler til telefonens OS (VAPID, push_subscription, service worker)
Scratch-verifiser backend for alle fire (27/27 sjekker gjort)
Browserverifiser alle fire i nettleser mot scratch-backend
Rull ut mot ekte systemer (migrasjoner + containere), med bekreftelse
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Alle fire er ferdigstilt, verifisert og live. Kort oppsummert:
PWA-installasjonsoppfordring — ekte beforeinstallprompt-fanging, Android-knapp + iOS-instruksjonsbanner, vist på dashbordet. Bekreftet at Chrome faktisk fyrte eventet i testøkten.
Flere flighter i én frittstående runde — løs gruppering av separate runder (delt UUID), "+ Legg til en flight til" gjenbruker hele opprett-flyten forhåndsutfylt, nytt kombinert leaderboard på tvers av flighter. Testet fra knappetrykk til ferdig runde, bekreftet i database.
Scramble/greensome: valgt utslag per spiller — nytt felt i hull-registreringen (helt valgfritt), "Utslag brukt"-oppsummering på statistikksiden. Bygget for frittstående runder; org-turneringer bevisst utenfor omfang denne runden (notert i backloggen).
Push-varsler til telefonens OS — Web Push/VAPID, ny av/på-bryter i kontoinnstillinger, dekker alle fire varseltyper automatisk. VAPID-nøkler generert og lagt i .env uten å vises i chatten. Én ærlig begrensning: ekte OS-varsel-levering med innvilget tillatelse kunne ikke testes i denne automatiserte nettleserøkten (tillatelsen var forhåndsblokkert av miljøet) — verdt å teste selv på en ekte enhet.
27/27 scratch-sjekker, full nettleser-gjennomgang uten konsollfeil, migrasjoner 035–037 kjørt mot ekte teecup_db, begge containere redeployet og verifisert, teeoff.no upåvirket. Alle tre .md-filene er oppdatert med full detalj.
2026-07-28 22:34:16 +02:00
|
|
|
<PushNotificationSection />
|
|
|
|
|
|
2026-08-04 11:34:28 +02:00
|
|
|
<MyCoursesSection />
|
|
|
|
|
|
2026-07-29 12:44:51 +02:00
|
|
|
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
<div className="flex items-center gap-2.5">
|
2026-08-02 16:34:54 +02:00
|
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
|
|
|
|
<ShieldCheck aria-hidden="true" className="size-5 text-muted-foreground" />
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
</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>
|
2026-08-07 22:02:56 +02:00
|
|
|
|
2026-08-08 12:34:03 +02:00
|
|
|
<BottomNav />
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
// --- 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))
|
2026-07-25 06:29:09 +02:00
|
|
|
// 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])
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-08-10 07:00:11 +02:00
|
|
|
// Aldersgrense (2026-08-10): TeeCup skal aldri la noen under 13 år
|
|
|
|
|
// registrere seg (GDPR/personopplysningsloven -- se ARCHITECTURE_
|
|
|
|
|
// DECISIONS.md). Denne klientside-sjekken er kun for rask tilbakemelding
|
|
|
|
|
// -- den ekte, håndhevede grensen ligger server-side i PATCH /auth/profile.
|
|
|
|
|
const underMinimumAge = birthDate !== "" && computeAge(birthDate) < 13
|
|
|
|
|
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
const valid =
|
|
|
|
|
firstName.trim() !== "" &&
|
|
|
|
|
lastName.trim() !== "" &&
|
|
|
|
|
birthDate !== "" &&
|
2026-08-10 07:00:11 +02:00
|
|
|
!underMinimumAge &&
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
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 {
|
2026-08-08 12:34:03 +02:00
|
|
|
router.replace("/logg-inn")
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}
|
2026-07-29 12:44:51 +02:00
|
|
|
className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6"
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
>
|
|
|
|
|
<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)}
|
2026-08-10 07:00:11 +02:00
|
|
|
aria-invalid={underMinimumAge}
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
className="h-11 rounded-xl"
|
|
|
|
|
/>
|
2026-08-10 07:00:11 +02:00
|
|
|
{underMinimumAge ? (
|
|
|
|
|
<p role="alert" className="text-sm text-destructive text-pretty">
|
|
|
|
|
Du må være minst 13 år for å bruke TeeCup.
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
</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>
|
2026-07-25 06:29:09 +02:00
|
|
|
<CountryField id="ob-country" value={country} onChange={setCountry} />
|
|
|
|
|
<HomeClubField id="ob-home-club" value={homeClub} onChange={setHomeClub} />
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
<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))
|
2026-07-25 06:29:09 +02:00
|
|
|
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 ?? "")
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
const [bio, setBio] = useState(me.bio ?? "")
|
2026-07-20 11:32:10 +02:00
|
|
|
const [mobileCountryCode, setMobileCountryCode] = useState(me.mobile_country_code ?? "+47")
|
|
|
|
|
const [mobileNumber, setMobileNumber] = useState(me.mobile_number ?? "")
|
2026-07-24 06:27:35 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-08-10 07:00:11 +02:00
|
|
|
// Aldersgrense (2026-08-10) -- se tilsvarende kommentar i ProfileOnboarding.
|
|
|
|
|
const underMinimumAge = birthDate !== "" && computeAge(birthDate) < 13
|
|
|
|
|
|
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
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault()
|
2026-08-10 07:00:11 +02:00
|
|
|
if (underMinimumAge) return
|
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
|
|
|
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(),
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
country: country.trim() === "" ? null : country.trim(),
|
|
|
|
|
bio: bio.trim() === "" ? null : bio.trim(),
|
2026-07-20 11:32:10 +02:00
|
|
|
mobile_country_code: mobileNumber.trim() === "" ? null : mobileCountryCode.trim(),
|
|
|
|
|
mobile_number: mobileNumber.trim() === "" ? null : mobileNumber.trim(),
|
2026-07-24 06:27:35 +02:00
|
|
|
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 (
|
2026-07-29 12:44:51 +02:00
|
|
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-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
|
|
|
<div className="flex items-center gap-2.5">
|
2026-08-02 16:34:54 +02:00
|
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
|
|
|
|
<User aria-hidden="true" className="size-5 text-muted-foreground" />
|
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>
|
|
|
|
|
<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"
|
2026-08-07 22:02:56 +02:00
|
|
|
// "image/*" -- mange mobilnettlesere viser bare galleri, ikke
|
|
|
|
|
// kamera, når accept lister flere eksplisitte MIME-typer.
|
|
|
|
|
accept="image/*"
|
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
|
|
|
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)}
|
2026-08-10 07:00:11 +02:00
|
|
|
aria-invalid={underMinimumAge}
|
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
|
|
|
className="h-11 rounded-xl"
|
|
|
|
|
/>
|
2026-08-10 07:00:11 +02:00
|
|
|
{underMinimumAge ? (
|
|
|
|
|
<p role="alert" className="text-sm text-destructive text-pretty">
|
|
|
|
|
Du må være minst 13 år for å bruke TeeCup.
|
|
|
|
|
</p>
|
|
|
|
|
) : 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
|
|
|
</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>
|
2026-07-25 06:29:09 +02:00
|
|
|
<CountryField id="country" value={country} onChange={setCountry} />
|
|
|
|
|
<HomeClubField id="home-club" value={homeClub} onChange={setHomeClub} />
|
Update Todos
Skriv migrasjon 019 (app_user.country + bio)
Backend: profile_complete-felt, ProfileUpdate country/bio/HCP-cap
Frontend: onboarding-visning i /account, gate i page.tsx + dashboard.tsx
Scratch-verifiser hele flyten
Be om bekreftelse og rull ut live
Scratch-verifisert, alt grønt:
Migrasjon 019 kjørte rent (001→019), test_isolation.sql 12/12 uendret.
16/16 backend-sjekker: fersk konto starter profile_complete: false, delvis utfylling forblir ufullstendig, HCP >54 avvist (422), full utfylling (inkl. HCP=54) gir profile_complete: true, beskrivelse er reelt valgfri, å nullstille et obligatorisk felt i etterkant slår profile_complete tilbake til false, full isolasjon mellom to kontoer.
Ekte produksjonsbuild av frontend kompilerte rent, alle 16 ruter listet.
Ekte HTTP-nivå-bevis mot en kjørende produksjonscontainer: anonym mot / → 200 (innloggingsskjema), en ekte innlogget-men-ufullstendig sesjonscookie mot / → 307 → /account.
Klar for ekte teecup_db. Dette krever:
Kjøre 019_profile_country_bio.sql mot ekte teecup_db (kun to nye nullable kolonner på app_user — additivt, ingen eksisterende rader berørt).
docker compose up -d --build teecup_api teecup_frontend.
Verdt å merke: med dette rullet ut vil både hei@erol.no og erol.haagenrud@envide.no bli sendt til profil-fullføring ved neste innlogging — ingen av kontoene har disse feltene utfylt i dag (bekreftet med spørringen tidligere).
2026-07-22 07:53:29 +02:00
|
|
|
<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>
|
2026-07-20 11:32:10 +02:00
|
|
|
<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>
|
|
|
|
|
|
2026-07-24 06:27:35 +02:00
|
|
|
<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>}
|
|
|
|
|
|
2026-08-10 07:00:11 +02:00
|
|
|
<Button type="submit" disabled={saving || underMinimumAge} className="h-11 w-fit rounded-xl font-semibold">
|
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
|
|
|
{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
|
|
|
|
Alt er ferdig, verifisert og live. Oppsummert:
Hullet lukket: hele WHS-HCP-motoren fantes og var testet, men ble aldri kalt — score_differential ble lagret per runde, men ingen indeks ble noensinne regnet ut. Nå har du:
To atskilte tall: manuelt satt HCP (uendret bruk) og et nytt, automatisk beregnet «faktisk HCP» (beste 8-av-≤20 nyeste tellende runder, WHS Rule 5.2, med Low-HCP-cap).
«Bruk som mitt HCP»-knapp på /account for å eksplisitt overføre.
Eksklusjon per deltaker — hver innlogget spiller (ikke bare eieren) styrer selv om egen deltakelse skal telle, uansett fullført-status.
Selvdeklarert spilleform (slagspill/matchspill) — matchspill forhåndsforeslår (ikke tvinger) eksklusjon, begrunnet i WHS sin «most likely score»-regel som TeeCup ikke kan garantere presist.
Verifisert med 111/111 scratch-sjekker (inkl. håndregnet WHS-matte) og en ekte nettleser-gjennomgang av hele flyten. Migrasjon 030 kjørt mot ekte teecup_db, begge containere redeployet, alt grønt, teeoff.no upåvirket.
Gjenstår (dokumentert i CLAUDE.md/FEATURE_BACKLOG.md, ikke bygget nå): offline-kø for frittstående runder, Stableford, rundedeling/visibility, flere flighter i én runde.
2026-07-28 11:04:51 +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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
Alt er ferdig, verifisert og live. Oppsummert:
Hullet lukket: hele WHS-HCP-motoren fantes og var testet, men ble aldri kalt — score_differential ble lagret per runde, men ingen indeks ble noensinne regnet ut. Nå har du:
To atskilte tall: manuelt satt HCP (uendret bruk) og et nytt, automatisk beregnet «faktisk HCP» (beste 8-av-≤20 nyeste tellende runder, WHS Rule 5.2, med Low-HCP-cap).
«Bruk som mitt HCP»-knapp på /account for å eksplisitt overføre.
Eksklusjon per deltaker — hver innlogget spiller (ikke bare eieren) styrer selv om egen deltakelse skal telle, uansett fullført-status.
Selvdeklarert spilleform (slagspill/matchspill) — matchspill forhåndsforeslår (ikke tvinger) eksklusjon, begrunnet i WHS sin «most likely score»-regel som TeeCup ikke kan garantere presist.
Verifisert med 111/111 scratch-sjekker (inkl. håndregnet WHS-matte) og en ekte nettleser-gjennomgang av hele flyten. Migrasjon 030 kjørt mot ekte teecup_db, begge containere redeployet, alt grønt, teeoff.no upåvirket.
Gjenstår (dokumentert i CLAUDE.md/FEATURE_BACKLOG.md, ikke bygget nå): offline-kø for frittstående runder, Stableford, rundedeling/visibility, flere flighter i én runde.
2026-07-28 11:04:51 +02:00
|
|
|
// 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 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"}
|
|
|
|
|
.
|
|
|
|
|
</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)
|
Alt er ferdig, verifisert og live. Oppsummert:
Hullet lukket: hele WHS-HCP-motoren fantes og var testet, men ble aldri kalt — score_differential ble lagret per runde, men ingen indeks ble noensinne regnet ut. Nå har du:
To atskilte tall: manuelt satt HCP (uendret bruk) og et nytt, automatisk beregnet «faktisk HCP» (beste 8-av-≤20 nyeste tellende runder, WHS Rule 5.2, med Low-HCP-cap).
«Bruk som mitt HCP»-knapp på /account for å eksplisitt overføre.
Eksklusjon per deltaker — hver innlogget spiller (ikke bare eieren) styrer selv om egen deltakelse skal telle, uansett fullført-status.
Selvdeklarert spilleform (slagspill/matchspill) — matchspill forhåndsforeslår (ikke tvinger) eksklusjon, begrunnet i WHS sin «most likely score»-regel som TeeCup ikke kan garantere presist.
Verifisert med 111/111 scratch-sjekker (inkl. håndregnet WHS-matte) og en ekte nettleser-gjennomgang av hele flyten. Migrasjon 030 kjørt mot ekte teecup_db, begge containere redeployet, alt grønt, teeoff.no upåvirket.
Gjenstår (dokumentert i CLAUDE.md/FEATURE_BACKLOG.md, ikke bygget nå): offline-kø for frittstående runder, Stableford, rundedeling/visibility, flere flighter i én runde.
2026-07-28 11:04:51 +02:00
|
|
|
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>
|
Alt er ferdig, verifisert og live. Oppsummert:
Hullet lukket: hele WHS-HCP-motoren fantes og var testet, men ble aldri kalt — score_differential ble lagret per runde, men ingen indeks ble noensinne regnet ut. Nå har du:
To atskilte tall: manuelt satt HCP (uendret bruk) og et nytt, automatisk beregnet «faktisk HCP» (beste 8-av-≤20 nyeste tellende runder, WHS Rule 5.2, med Low-HCP-cap).
«Bruk som mitt HCP»-knapp på /account for å eksplisitt overføre.
Eksklusjon per deltaker — hver innlogget spiller (ikke bare eieren) styrer selv om egen deltakelse skal telle, uansett fullført-status.
Selvdeklarert spilleform (slagspill/matchspill) — matchspill forhåndsforeslår (ikke tvinger) eksklusjon, begrunnet i WHS sin «most likely score»-regel som TeeCup ikke kan garantere presist.
Verifisert med 111/111 scratch-sjekker (inkl. håndregnet WHS-matte) og en ekte nettleser-gjennomgang av hele flyten. Migrasjon 030 kjørt mot ekte teecup_db, begge containere redeployet, alt grønt, teeoff.no upåvirket.
Gjenstår (dokumentert i CLAUDE.md/FEATURE_BACKLOG.md, ikke bygget nå): offline-kø for frittstående runder, Stableford, rundedeling/visibility, flere flighter i én runde.
2026-07-28 11:04:51 +02:00
|
|
|
<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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 11:32:10 +02:00
|
|
|
// --- 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 (
|
2026-07-29 12:44:51 +02:00
|
|
|
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
2026-07-20 11:32:10 +02:00
|
|
|
<div className="flex items-center gap-2.5">
|
2026-08-02 16:34:54 +02:00
|
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
|
|
|
|
<Mail aria-hidden="true" className="size-5 text-muted-foreground" />
|
2026-07-20 11:32:10 +02:00
|
|
|
</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 (
|
2026-07-29 12:44:51 +02:00
|
|
|
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
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
|
|
|
<div className="flex items-center gap-2.5">
|
2026-08-02 16:34:54 +02:00
|
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
|
|
|
|
<Mail aria-hidden="true" className="size-5 text-muted-foreground" />
|
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
|
|
|
</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 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.
|
|
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
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 (
|
2026-07-29 12:44:51 +02:00
|
|
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
<div className="flex items-center gap-2.5">
|
2026-08-02 16:34:54 +02:00
|
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
|
|
|
|
<Lock aria-hidden="true" className="size-5 text-muted-foreground" />
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-07-28 20:09:21 +02:00
|
|
|
|
|
|
|
|
// --- 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." },
|
2026-08-10 18:34:31 +02:00
|
|
|
{ value: "round", label: "Runder", description: "Du blir lagt til som medspiller på en runde." },
|
2026-07-28 20:09:21 +02:00
|
|
|
{ value: "result", label: "Resultater", description: "En runde du er koblet til blir fullført." },
|
|
|
|
|
{ value: "tournament", label: "Turneringer", description: "Reservert for fremtidige turnering-varsler." },
|
|
|
|
|
]
|
|
|
|
|
|
Update Todos
Bygg PWA-installasjonsoppfordring (Android beforeinstallprompt + iOS instruksjonsbanner)
Design og bygg flere flighter i én frittstående runde (løs gruppering, retning 1)
Design og bygg scramble/greensome-statistikk (valgt utslag per spiller)
Bygg push-varsler til telefonens OS (VAPID, push_subscription, service worker)
Scratch-verifiser backend for alle fire (27/27 sjekker gjort)
Browserverifiser alle fire i nettleser mot scratch-backend
Rull ut mot ekte systemer (migrasjoner + containere), med bekreftelse
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Alle fire er ferdigstilt, verifisert og live. Kort oppsummert:
PWA-installasjonsoppfordring — ekte beforeinstallprompt-fanging, Android-knapp + iOS-instruksjonsbanner, vist på dashbordet. Bekreftet at Chrome faktisk fyrte eventet i testøkten.
Flere flighter i én frittstående runde — løs gruppering av separate runder (delt UUID), "+ Legg til en flight til" gjenbruker hele opprett-flyten forhåndsutfylt, nytt kombinert leaderboard på tvers av flighter. Testet fra knappetrykk til ferdig runde, bekreftet i database.
Scramble/greensome: valgt utslag per spiller — nytt felt i hull-registreringen (helt valgfritt), "Utslag brukt"-oppsummering på statistikksiden. Bygget for frittstående runder; org-turneringer bevisst utenfor omfang denne runden (notert i backloggen).
Push-varsler til telefonens OS — Web Push/VAPID, ny av/på-bryter i kontoinnstillinger, dekker alle fire varseltyper automatisk. VAPID-nøkler generert og lagt i .env uten å vises i chatten. Én ærlig begrensning: ekte OS-varsel-levering med innvilget tillatelse kunne ikke testes i denne automatiserte nettleserøkten (tillatelsen var forhåndsblokkert av miljøet) — verdt å teste selv på en ekte enhet.
27/27 scratch-sjekker, full nettleser-gjennomgang uten konsollfeil, migrasjoner 035–037 kjørt mot ekte teecup_db, begge containere redeployet og verifisert, teeoff.no upåvirket. Alle tre .md-filene er oppdatert med full detalj.
2026-07-28 22:34:16 +02:00
|
|
|
// 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<boolean | null>(null)
|
|
|
|
|
const [subscribed, setSubscribed] = useState(false)
|
|
|
|
|
const [busy, setBusy] = useState(false)
|
|
|
|
|
const [message, setMessage] = useState<string | null>(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 (
|
2026-07-29 12:44:51 +02:00
|
|
|
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
Update Todos
Bygg PWA-installasjonsoppfordring (Android beforeinstallprompt + iOS instruksjonsbanner)
Design og bygg flere flighter i én frittstående runde (løs gruppering, retning 1)
Design og bygg scramble/greensome-statistikk (valgt utslag per spiller)
Bygg push-varsler til telefonens OS (VAPID, push_subscription, service worker)
Scratch-verifiser backend for alle fire (27/27 sjekker gjort)
Browserverifiser alle fire i nettleser mot scratch-backend
Rull ut mot ekte systemer (migrasjoner + containere), med bekreftelse
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Alle fire er ferdigstilt, verifisert og live. Kort oppsummert:
PWA-installasjonsoppfordring — ekte beforeinstallprompt-fanging, Android-knapp + iOS-instruksjonsbanner, vist på dashbordet. Bekreftet at Chrome faktisk fyrte eventet i testøkten.
Flere flighter i én frittstående runde — løs gruppering av separate runder (delt UUID), "+ Legg til en flight til" gjenbruker hele opprett-flyten forhåndsutfylt, nytt kombinert leaderboard på tvers av flighter. Testet fra knappetrykk til ferdig runde, bekreftet i database.
Scramble/greensome: valgt utslag per spiller — nytt felt i hull-registreringen (helt valgfritt), "Utslag brukt"-oppsummering på statistikksiden. Bygget for frittstående runder; org-turneringer bevisst utenfor omfang denne runden (notert i backloggen).
Push-varsler til telefonens OS — Web Push/VAPID, ny av/på-bryter i kontoinnstillinger, dekker alle fire varseltyper automatisk. VAPID-nøkler generert og lagt i .env uten å vises i chatten. Én ærlig begrensning: ekte OS-varsel-levering med innvilget tillatelse kunne ikke testes i denne automatiserte nettleserøkten (tillatelsen var forhåndsblokkert av miljøet) — verdt å teste selv på en ekte enhet.
27/27 scratch-sjekker, full nettleser-gjennomgang uten konsollfeil, migrasjoner 035–037 kjørt mot ekte teecup_db, begge containere redeployet og verifisert, teeoff.no upåvirket. Alle tre .md-filene er oppdatert med full detalj.
2026-07-28 22:34:16 +02:00
|
|
|
<div className="flex items-center gap-2.5">
|
2026-08-02 16:34:54 +02:00
|
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
|
|
|
|
<BellRing aria-hidden="true" className="size-5 text-muted-foreground" />
|
Update Todos
Bygg PWA-installasjonsoppfordring (Android beforeinstallprompt + iOS instruksjonsbanner)
Design og bygg flere flighter i én frittstående runde (løs gruppering, retning 1)
Design og bygg scramble/greensome-statistikk (valgt utslag per spiller)
Bygg push-varsler til telefonens OS (VAPID, push_subscription, service worker)
Scratch-verifiser backend for alle fire (27/27 sjekker gjort)
Browserverifiser alle fire i nettleser mot scratch-backend
Rull ut mot ekte systemer (migrasjoner + containere), med bekreftelse
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Alle fire er ferdigstilt, verifisert og live. Kort oppsummert:
PWA-installasjonsoppfordring — ekte beforeinstallprompt-fanging, Android-knapp + iOS-instruksjonsbanner, vist på dashbordet. Bekreftet at Chrome faktisk fyrte eventet i testøkten.
Flere flighter i én frittstående runde — løs gruppering av separate runder (delt UUID), "+ Legg til en flight til" gjenbruker hele opprett-flyten forhåndsutfylt, nytt kombinert leaderboard på tvers av flighter. Testet fra knappetrykk til ferdig runde, bekreftet i database.
Scramble/greensome: valgt utslag per spiller — nytt felt i hull-registreringen (helt valgfritt), "Utslag brukt"-oppsummering på statistikksiden. Bygget for frittstående runder; org-turneringer bevisst utenfor omfang denne runden (notert i backloggen).
Push-varsler til telefonens OS — Web Push/VAPID, ny av/på-bryter i kontoinnstillinger, dekker alle fire varseltyper automatisk. VAPID-nøkler generert og lagt i .env uten å vises i chatten. Én ærlig begrensning: ekte OS-varsel-levering med innvilget tillatelse kunne ikke testes i denne automatiserte nettleserøkten (tillatelsen var forhåndsblokkert av miljøet) — verdt å teste selv på en ekte enhet.
27/27 scratch-sjekker, full nettleser-gjennomgang uten konsollfeil, migrasjoner 035–037 kjørt mot ekte teecup_db, begge containere redeployet og verifisert, teeoff.no upåvirket. Alle tre .md-filene er oppdatert med full detalj.
2026-07-28 22:34:16 +02:00
|
|
|
</div>
|
|
|
|
|
<h2 className="text-base font-bold text-foreground">Push-varsler på denne enheten</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
|
|
|
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.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
{supported === false && isIOS && !isStandalone && (
|
|
|
|
|
<p className="rounded-xl border border-dashed border-border bg-muted/40 px-3 py-2.5 text-sm text-muted-foreground">
|
|
|
|
|
På iPhone/iPad må TeeCup først legges til på hjemskjermen (Del-ikonet i Safari → «Legg til på
|
|
|
|
|
Hjem-skjerm») før push-varsler kan slås på.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{supported === false && !isIOS && (
|
|
|
|
|
<p className="rounded-xl border border-dashed border-border bg-muted/40 px-3 py-2.5 text-sm text-muted-foreground">
|
|
|
|
|
Nettleseren din støtter ikke push-varsler.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{supported && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => void toggle()}
|
|
|
|
|
disabled={busy}
|
|
|
|
|
className={`inline-flex min-h-11 w-fit items-center gap-2 rounded-xl border px-4 text-sm font-bold transition-colors disabled:opacity-60 ${
|
|
|
|
|
subscribed
|
|
|
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
|
|
|
: "border-border bg-card text-foreground hover:bg-accent/50"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<BellRing aria-hidden="true" className="size-4" />
|
|
|
|
|
{busy ? "Vent…" : subscribed ? "Push-varsler er PÅ -- slå av" : "Slå på push-varsler"}
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{message && <p className="text-sm text-muted-foreground">{message}</p>}
|
|
|
|
|
</section>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 11:34:28 +02:00
|
|
|
// --- Mine baner (2026-08-04) -------------------------------------------------
|
|
|
|
|
// Administrasjon av `personal_course`-rader brukeren eier -- det delte,
|
|
|
|
|
// plattform-omfattende bane-biblioteket som både turneringsmodulen og
|
|
|
|
|
// single-runde-modulen nå bruker som mal-kilde. Redigering skjer i sted
|
|
|
|
|
// (PATCH, samme eier -- ingen fork utløses); dupliser/slett er egne,
|
|
|
|
|
// eksplisitte handlinger. Andres baner vises IKKE her -- kun i mal-søket
|
|
|
|
|
// i de respektive modulene (denne siden er ren eierskapsadministrasjon).
|
|
|
|
|
|
|
|
|
|
type ApiMyCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
|
|
|
|
|
type ApiMyCourseDetail = {
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
holes: CourseTemplateValues["holes"]
|
|
|
|
|
full_tees: CourseTemplateValues["tees"]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MyCoursesSection() {
|
|
|
|
|
const [courses, setCourses] = useState<ApiMyCourse[] | null>(null)
|
|
|
|
|
const [editingId, setEditingId] = useState<string | null>(null)
|
|
|
|
|
const [editingInitial, setEditingInitial] = useState<CourseTemplateValues | null>(null)
|
|
|
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
const res = await fetch("/personal-courses?mine=true", { credentials: "include" })
|
|
|
|
|
if (res.ok) setCourses(await res.json())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
void load()
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
async function startEdit(id: string) {
|
|
|
|
|
setError(null)
|
|
|
|
|
const res = await fetch(`/personal-courses/${id}`, { credentials: "include" })
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
setError("Klarte ikke å hente banedetaljer.")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const detail: ApiMyCourseDetail = await res.json()
|
|
|
|
|
setEditingInitial({ name: detail.name, holes: detail.holes, tees: detail.full_tees })
|
|
|
|
|
setEditingId(id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveEdit(values: CourseTemplateValues) {
|
|
|
|
|
if (!editingId) return
|
|
|
|
|
setSubmitting(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/personal-courses/${editingId}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
credentials: "include",
|
|
|
|
|
body: JSON.stringify(values),
|
|
|
|
|
})
|
|
|
|
|
if (!res.ok) throw new Error(`update: ${res.status}`)
|
|
|
|
|
setEditingId(null)
|
|
|
|
|
setEditingInitial(null)
|
|
|
|
|
await load()
|
|
|
|
|
} catch {
|
|
|
|
|
setError("Klarte ikke å lagre endringene.")
|
|
|
|
|
} finally {
|
|
|
|
|
setSubmitting(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function duplicate(id: string) {
|
|
|
|
|
setError(null)
|
|
|
|
|
const res = await fetch(`/personal-courses/${id}/duplicate`, { method: "POST", credentials: "include" })
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
setError("Klarte ikke å duplisere banen.")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
await load()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function remove(id: string) {
|
|
|
|
|
if (!confirm("Slette denne banen? Dette kan ikke angres.")) return
|
|
|
|
|
setError(null)
|
|
|
|
|
const res = await fetch(`/personal-courses/${id}`, { method: "DELETE", credentials: "include" })
|
|
|
|
|
if (res.status === 204) {
|
|
|
|
|
await load()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const body = await res.json().catch(() => null)
|
|
|
|
|
setError(
|
|
|
|
|
body?.detail?.code === "IN_USE"
|
|
|
|
|
? "Denne banen er i bruk i én eller flere runder og kan ikke slettes."
|
|
|
|
|
: "Klarte ikke å slette banen.",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
|
|
|
|
<div className="flex items-center gap-2.5">
|
|
|
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
|
|
|
|
<Flag aria-hidden="true" className="size-5 text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<h2 className="text-base font-bold text-foreground">Mine baner</h2>
|
|
|
|
|
<p className="text-xs text-muted-foreground text-pretty">
|
|
|
|
|
Egendefinerte baner du har opprettet -- offentlig synlige og brukbare som mal av andre,
|
|
|
|
|
i både turneringsmodulen og single-runde-modulen.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
|
|
|
|
|
|
|
|
{editingId ? (
|
|
|
|
|
<CourseTemplateEditor
|
|
|
|
|
initial={editingInitial ?? undefined}
|
|
|
|
|
submitLabel="Lagre endringer"
|
|
|
|
|
submitting={submitting}
|
|
|
|
|
error={null}
|
|
|
|
|
onSubmit={saveEdit}
|
|
|
|
|
onCancel={() => {
|
|
|
|
|
setEditingId(null)
|
|
|
|
|
setEditingInitial(null)
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
) : courses === null ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground">Laster…</p>
|
|
|
|
|
) : courses.length === 0 ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Ingen egne baner ennå -- opprett en via "Egen bane" i ny-runde-veiviseren, eller "Opprett
|
|
|
|
|
bane med hull/utslag" i en turnering.
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
|
|
|
|
|
{courses.map((c) => (
|
|
|
|
|
<li key={c.id} className="flex items-center gap-3 px-4 py-3">
|
|
|
|
|
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{c.name}</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => startEdit(c.id)}
|
|
|
|
|
aria-label={`Rediger ${c.name}`}
|
|
|
|
|
className="size-9 rounded-lg text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
<Pencil aria-hidden="true" className="size-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => duplicate(c.id)}
|
|
|
|
|
aria-label={`Dupliser ${c.name}`}
|
|
|
|
|
className="size-9 rounded-lg text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
<Copy aria-hidden="true" className="size-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => remove(c.id)}
|
|
|
|
|
aria-label={`Slett ${c.name}`}
|
|
|
|
|
className="size-9 rounded-lg text-muted-foreground hover:text-destructive"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 aria-hidden="true" className="size-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 20:09:21 +02:00
|
|
|
function NotificationEmailPrefsSection() {
|
|
|
|
|
const [types, setTypes] = useState<string[]>([])
|
|
|
|
|
const [loaded, setLoaded] = useState(false)
|
|
|
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
const [success, setSuccess] = useState(false)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetch("/notifications/email-prefs", { credentials: "include" })
|
|
|
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
|
|
|
.then((data: { types: string[] } | null) => {
|
|
|
|
|
if (data) setTypes(data.types)
|
|
|
|
|
setLoaded(true)
|
|
|
|
|
})
|
|
|
|
|
.catch(() => setLoaded(true))
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
async function save(next: string[]) {
|
|
|
|
|
setTypes(next)
|
|
|
|
|
setSaving(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
setSuccess(false)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch("/notifications/email-prefs", {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
credentials: "include",
|
|
|
|
|
body: JSON.stringify({ types: next }),
|
|
|
|
|
})
|
|
|
|
|
if (!res.ok) throw new Error("Klarte ikke å lagre varselinnstillingene.")
|
|
|
|
|
setSuccess(true)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-29 12:44:51 +02:00
|
|
|
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
2026-07-28 20:09:21 +02:00
|
|
|
<div className="flex items-center gap-2.5">
|
2026-08-02 16:34:54 +02:00
|
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
|
|
|
|
<Bell aria-hidden="true" className="size-5 text-muted-foreground" />
|
2026-07-28 20:09:21 +02:00
|
|
|
</div>
|
|
|
|
|
<h2 className="text-base font-bold text-foreground">Varsler på e-post</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
|
|
|
Du får alltid varsler i appen (klokke-ikonet). Velg her hvilke typer du i tillegg vil ha på
|
|
|
|
|
e-post -- ingen sendes med mindre du krysser av.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
{loaded && (
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
{NOTIFICATION_TYPE_OPTIONS.map((opt) => {
|
|
|
|
|
const checked = types.includes(opt.value)
|
|
|
|
|
return (
|
|
|
|
|
<label
|
|
|
|
|
key={opt.value}
|
|
|
|
|
className={`flex min-h-11 items-start gap-3 rounded-xl border px-3 py-2.5 ${
|
|
|
|
|
checked ? "border-primary bg-primary/10" : "border-border"
|
|
|
|
|
} ${saving ? "opacity-70" : "cursor-pointer"}`}
|
|
|
|
|
>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={checked}
|
|
|
|
|
disabled={saving}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
void save(e.target.checked ? [...types, opt.value] : types.filter((t) => t !== opt.value))
|
|
|
|
|
}
|
|
|
|
|
className="mt-0.5 size-4 shrink-0 rounded border-border"
|
|
|
|
|
/>
|
|
|
|
|
<span className="flex flex-col">
|
|
|
|
|
<span className="text-sm font-semibold text-foreground">{opt.label}</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground text-pretty">{opt.description}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
|
|
|
{success && <p className="text-sm font-medium text-primary">Lagret.</p>}
|
|
|
|
|
</section>
|
|
|
|
|
)
|
|
|
|
|
}
|