13-årsgrense — håndhevet server-side (eneste vei inn i appen, ADR-051) og med umiddelbar rød feilmelding klientside i begge profil-skjemaer. Testet på nøyaktig dagsgrensen.
PWA-installasjonstekst — skiller nå Safari/Chrome på iOS, ikke lenger feilaktig Safari-only. Beskrivelsestekst — "golfklubber" fjernet, nytt punkt om at turneringsadmin/-presentasjon fungerer godt på PC. Dette krever både teecup_api og teecup_frontend (ingen migrasjon).
This commit is contained in:
parent
26313c8017
commit
2562e20ec3
23 changed files with 1417 additions and 292 deletions
|
|
@ -451,7 +451,21 @@
|
||||||
"Bash(curl -s -o /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/zero_dist.json -w '\\\\nHTTP: %{http_code}\\\\n' -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/s19_jar.txt -X POST http://172.18.0.11:8000/auth/request-link -H 'Content-Type: application/json' -d '{\"email\":\"zerodist@example.com\",\"locale\":\"nb\"}')",
|
"Bash(curl -s -o /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/zero_dist.json -w '\\\\nHTTP: %{http_code}\\\\n' -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/s19_jar.txt -X POST http://172.18.0.11:8000/auth/request-link -H 'Content-Type: application/json' -d '{\"email\":\"zerodist@example.com\",\"locale\":\"nb\"}')",
|
||||||
"Bash(awk -F: '$1 >= 2047 && $1 <= 2260')",
|
"Bash(awk -F: '$1 >= 2047 && $1 <= 2260')",
|
||||||
"Bash(curl -s -o /dev/null -w 'HTTP: %{http_code}\\\\n' -H 'Referer: https://teecup.golf/' https://api.mapbox.com/styles/v1/mapbox/satellite-v9?access_token=pk.eyJ1IjoiZXJvbGhhIiwiYSI6ImNtc2s5Z2VsajByYTgyeHMya2JteXhoZTEifQ.NlHI8NpiYTLh9PBzpSXyUw)",
|
"Bash(curl -s -o /dev/null -w 'HTTP: %{http_code}\\\\n' -H 'Referer: https://teecup.golf/' https://api.mapbox.com/styles/v1/mapbox/satellite-v9?access_token=pk.eyJ1IjoiZXJvbGhhIiwiYSI6ImNtc2s5Z2VsajByYTgyeHMya2JteXhoZTEifQ.NlHI8NpiYTLh9PBzpSXyUw)",
|
||||||
"Bash(sort -t/ -k4 -n)"
|
"Bash(sort -t/ -k4 -n)",
|
||||||
|
"Bash(echo \"building in bg, pid $!\")",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18124/)",
|
||||||
|
"Bash(xargs -I{} sh -c 'echo \"--- {} ---\"; head -3 \"{}\"')",
|
||||||
|
"Bash(grep -n \"</main>\\\\|</div>$\" frontend/components/teecup/velkommen.tsx)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18126/)",
|
||||||
|
"Bash(xargs -I{} sh -c 'echo \"--- {} ---\"')",
|
||||||
|
"Bash(grep -iE \"\\\\.env$|\\\\.pem$|secret|credential\")",
|
||||||
|
"Bash(grep -B3 \": str$\")",
|
||||||
|
"Bash(python3 -m py_compile app/rate_limit.py app/routers/auth.py)",
|
||||||
|
"Bash(curl -sI https://teecup.golf/)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teecup.golf/)",
|
||||||
|
"Bash(echo \"EXIT CODE: $?\")",
|
||||||
|
"Bash(python3 -m json.tool /tmp/adapted.json)",
|
||||||
|
"Bash(curl -s -o /dev/null -w \"teecup.golf: %{http_code}\\\\n\" https://teecup.golf/)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
|
|
@ -4696,6 +4696,70 @@ endret beslutning får et tillegg, ikke en retusjert original.
|
||||||
`start_method=gps, end_method=map_tap` i databasen, verifisert med
|
`start_method=gps, end_method=map_tap` i databasen, verifisert med
|
||||||
direkte SQL). Se CHANGELOG.md for full detalj.
|
direkte SQL). Se CHANGELOG.md for full detalj.
|
||||||
|
|
||||||
|
**Tillegg 2026-08-08 (del 2) — ball-steget viser nå ALLTID kartet, med
|
||||||
|
løpende (sanntids) posisjon og avstand, og allerede målte slag viser nå
|
||||||
|
satellittfoto i listen.** Umiddelbart etter tillegget over, bruker: "Jeg
|
||||||
|
får ikke sett slagene jeg allerede har målt... jeg ønsker å kunne se på et
|
||||||
|
satelittfoto hvor jeg har slått hvert slag. Det andre er at selv om jeg
|
||||||
|
velger 'min posisjon nå', så skal jeg se start og slutt på et
|
||||||
|
satelittfoto... I alle sammenhenger... ønsker jeg at jeg skal se lengden
|
||||||
|
så langt i sanntid mens jeg nærmer meg ballen." Dette endrer Beslutning B
|
||||||
|
sitt "kart lastes kun ved eksplisitt 'velg punkt på kart'"-prinsipp
|
||||||
|
BEVISST for ball-steget spesifikt (fortsatt uendret for start-steget):
|
||||||
|
|
||||||
|
- **Ball-steget slo sammen "GPS vs. kart"-valget til ÉN alltid-synlig
|
||||||
|
kart-visning.** Den forrige separate GPS-only-idle-skjermen (bare en
|
||||||
|
"Jeg er ved ballen nå"-knapp, ingen kart) og det egne `end_map`-steget
|
||||||
|
er fjernet -- `MapPointPicker` rendres nå direkte når "end"-steget nås,
|
||||||
|
alltid med `referencePoint=startPoint`. `MapPointPicker` viste
|
||||||
|
opprinnelig BEGGE bekreftelsesknappene når `referencePoint` var satt:
|
||||||
|
"Bekreft ballens posisjon" (trykket punkt, `end_method: "map_tap"`) og
|
||||||
|
"Jeg er ved ballen nå" (siste sporede posisjon, `end_method: "gps"`).
|
||||||
|
**Korrigert 2026-08-09** (samme dag, etter faktisk bruk): bruker,
|
||||||
|
"bekreft ballens posisjon er unødvendig" -- trykk-for-å-plassere-ballen
|
||||||
|
fjernet igjen. Ball-steget bekrefter nå UTELUKKENDE via sporet
|
||||||
|
GPS-posisjon; kartet der er rent informativt (referansepunkt +
|
||||||
|
sanntidsposisjon/-avstand), ikke lenger klikkbart for plassering.
|
||||||
|
Backendens `end_method`-kolonne/CHECK aksepterer fortsatt begge verdier
|
||||||
|
uendret (historiske `map_tap`-rader fra den korte perioden dette var
|
||||||
|
aktivt skal fortsatt leses korrekt) -- kun frontend-UI-et endret, ingen
|
||||||
|
migrasjon. Start-steget er UPÅVIRKET: trykk-for-å-plassere fungerer der
|
||||||
|
fortsatt akkurat som før.
|
||||||
|
- **Løpende posisjonssporing (`watchPosition`), kun på ball-steget.**
|
||||||
|
Dette er et bevisst, avgrenset unntak fra "ingen løpende watchPosition"
|
||||||
|
-- den opprinnelige begrunnelsen for det forbudet var KART-
|
||||||
|
lastnings-kostnad (Mapbox fakturerer initialisering), og `watchPosition`
|
||||||
|
i seg selv utløser ALDRI en ny kartinitialisering eller et nytt
|
||||||
|
Mapbox-API-kall -- det er ren nettleser-GPS som kun oppdaterer en
|
||||||
|
eksisterende markørs posisjon på det ALLEREDE lastede kartet. Samme
|
||||||
|
ETT-kart-instans-prinsipp står dermed fortsatt fast; kun selve
|
||||||
|
markør-/avstandsdataene oppdateres kontinuerlig. `watchPosition` ryddes
|
||||||
|
opp (`clearWatch`) når komponenten avmonteres.
|
||||||
|
- **Sanntids-avstand** vises som et stort tall nederst på kartet,
|
||||||
|
beregnet med `haversineMeters(referencePoint, livePosition)` (samme
|
||||||
|
rene funksjon som allerede fantes i `frontend/lib/geo.ts`, tidligere
|
||||||
|
brukt kun for forhåndsvisningen -- `shot-measurement-sheet.tsx` sin
|
||||||
|
egen duplikate Haversine-implementasjon fjernet til fordel for denne,
|
||||||
|
ren opprydding uten atferdsendring).
|
||||||
|
- **Kostnadskonsekvens, eksplisitt notert:** ball-steget laster nå
|
||||||
|
Mapbox-kartet for HVERT målt slag, uansett om brukeren til slutt
|
||||||
|
bekrefter via GPS eller kart-trykk (siden kartet uansett vises for at
|
||||||
|
brukeren skal se referansepunktet + sanntids-avstand mens de går).
|
||||||
|
Start-steget er UENDRET (kart lastes fortsatt kun ved eksplisitt "velg
|
||||||
|
punkt på kart") -- kun halvparten av et målt slags to punkter utløser nå
|
||||||
|
alltid en kartlastning. Vurdert og akseptert: 50 000 gratis
|
||||||
|
kartlastninger/mnd (Beslutning A) gir god margin for forventet volum.
|
||||||
|
- **Allerede målte slag i listen** (`round-detail.tsx`, `ShotList`) viser
|
||||||
|
nå et lite satellitt-thumbnail (160×160, samme offentlige, URL-
|
||||||
|
restrikterte token og samme `pin-s-a`/`pin-s-b`-fargekonvensjon som
|
||||||
|
forhåndsvisningen) bygget direkte fra `ShotRecord`s allerede-returnerte
|
||||||
|
`start_lat`/`start_lng`/`end_lat`/`end_lng` (utvidet fra kun
|
||||||
|
`id`/`club`/`distance_meters`/`shared_round_message_id`) -- løser "jeg
|
||||||
|
burde jo se slaget selv, selv om det ikke er delt" sitt neste lag: ikke
|
||||||
|
bare klubbe/avstand som tekst, men faktisk HVOR slaget ble slått. Ingen
|
||||||
|
backend-endring nødvendig -- `ShotOut` returnerte allerede alle fire
|
||||||
|
koordinatene, kun frontend-typen/rendringen manglet dem.
|
||||||
|
|
||||||
**Backend:** `app/routers/rounds.py` fikk `ShotIn`/`ShotOut`/`ShotShareIn`
|
**Backend:** `app/routers/rounds.py` fikk `ShotIn`/`ShotOut`/`ShotShareIn`
|
||||||
og seks nye endepunkter (deltaker-GET/POST, side-GET/POST, DELETE, share),
|
og seks nye endepunkter (deltaker-GET/POST, side-GET/POST, DELETE, share),
|
||||||
alle bygget på den eksisterende `_get_accessible_round_or_404` (samme
|
alle bygget på den eksisterende `_get_accessible_round_or_404` (samme
|
||||||
|
|
@ -4729,6 +4793,257 @@ Beslutning B).
|
||||||
og mål på nytt i stedet), en historisk "vis alle slag på kart etter
|
og mål på nytt i stedet), en historisk "vis alle slag på kart etter
|
||||||
runden"-visualisering.
|
runden"-visualisering.
|
||||||
|
|
||||||
|
## ADR-049: `/velkommen` — landingsside for innlogget-men-ikke-fullført-profil
|
||||||
|
|
||||||
|
Reist av brukeren 2026-08-09: "Skjemaet med personlig informasjon vises
|
||||||
|
for tidlig for ikke registrerte spillere etter at de logger seg inn...
|
||||||
|
Jeg tror det beste er at man kommer til en side med oppfordring til å
|
||||||
|
installere som app (som vanlig) og med deaktiverte knapper for runde,
|
||||||
|
turneringen og bli med med kode. Trykker man på noen av disse skal man
|
||||||
|
få beskjed om at personlig informasjon må fylles ut først, med lenke til
|
||||||
|
skjemaet. Under dette bør TeeCup presenteres." Bekreftet: ny, avgrenset
|
||||||
|
side (ikke en full erstatning av `/dashboard`, se drøftingen i chatten)
|
||||||
|
— dashbordet for FULLFØRT profil er uendret.
|
||||||
|
|
||||||
|
**Problemet:** en innlogget bruker med ufullstendig profil ble tidligere
|
||||||
|
sendt RETT til `/account` sitt påtvungne skjema ("Fullfør profilen din"),
|
||||||
|
uten noen kontekst om hva de nettopp logget seg inn på. Brukeren
|
||||||
|
observerte dette som en reell feil i praksis (antas: forvirring/frafall),
|
||||||
|
ikke bare en teoretisk innvending.
|
||||||
|
|
||||||
|
**Løsning: ny side `frontend/app/velkommen/page.tsx` +
|
||||||
|
`frontend/components/teecup/velkommen.tsx`,** satt inn i redirect-kjeden
|
||||||
|
mellom innlogging og `/account`:
|
||||||
|
- `frontend/app/page.tsx` og `frontend/app/logg-inn/page.tsx` sine
|
||||||
|
server-side redirects, OG `dashboard.tsx` sin klient-side
|
||||||
|
`profile_complete`-vakt (`loadMe()`), peker nå til `/velkommen` i
|
||||||
|
stedet for `/account` når profilen er ufullstendig. `/velkommen` selv
|
||||||
|
redirecter videre til `/dashboard` (komplett profil) eller `/logg-inn`
|
||||||
|
(ikke innlogget) — kan altså ikke nås "feil" via direkte URL.
|
||||||
|
- Siden viser, i rekkefølge: TeeCup-ordmerke + "Logg ut", en
|
||||||
|
personlig hilsen (`me.first_name ?? me.display_name`, samme
|
||||||
|
navneformat-regel som dashbordets hilsen, CLAUDE.md), den
|
||||||
|
eksisterende `InstallPrompt`-komponenten UENDRET (samme PWA-
|
||||||
|
oppfordring som dashbordet allerede bruker), en primær "Fullfør
|
||||||
|
profilen din"-CTA, TRE synlige men visuelt låste hurtighandlinger
|
||||||
|
("Ny runde"/"Ny turnering"/"Bli med med kode" — hengelås-ikon,
|
||||||
|
`aria-disabled`, IKKE HTML `disabled` siden de fortsatt skal være
|
||||||
|
klikkbare for å forklare hvorfor), og en presentasjon av TeeCup
|
||||||
|
(innhold hentet fra `teecup-beskrivelse.md`, skrevet om til kort
|
||||||
|
UI-tekst — ikke limt inn rått som markdown).
|
||||||
|
- Trykk på en låst handling viser en delt påminnelse
|
||||||
|
(`role="alert"`, skjermleser-varslet automatisk) med lenke videre til
|
||||||
|
`/account`, i stedet for å navigere — samme "forklar, ikke bare
|
||||||
|
blokkér"-prinsipp brukeren ba om.
|
||||||
|
- Bygget med samme "clubhouse"-palett-tokens (`--tee-strong`,
|
||||||
|
`--clubhouse-*`) og samme `TeeCupWordmark`/`InstallPrompt`-komponenter
|
||||||
|
som `/logg-inn` og det allerede reskinnede dashbordet (2026-08-08,
|
||||||
|
se CHANGELOG.md) — ikke funnet opp på nytt, gjenbruker den etablerte
|
||||||
|
retningen for akkurat denne delen av appen.
|
||||||
|
|
||||||
|
**Bevisst avgrenset omfang:** ingen ny server-side håndheving av
|
||||||
|
`profile_complete` er lagt til på andre sider (`/my-rounds`,
|
||||||
|
`/my-friends` osv. sjekker i dag kun innlogging, ikke profil-status —
|
||||||
|
en pre-eksisterende, ikke relatert inkonsistens, ikke rørt her). Kun de
|
||||||
|
tre inngangspunktene som faktisk styrte hvor en ufullstendig-profil-
|
||||||
|
bruker havnet (root, `/logg-inn`, dashbordets egen klient-vakt) er
|
||||||
|
endret.
|
||||||
|
|
||||||
|
## ADR-050: Sikkerhetsgjennomgang 2026-08-09 — rate limiting, sikkerhetshoder, inputvalidering
|
||||||
|
|
||||||
|
Brukeren ba om en full sikkerhetsgjennomgang ("sjekk alle felter som kan
|
||||||
|
fylles ut, og sjekk alle URL-er... lar siden/appen seg hacke?"). En
|
||||||
|
dedikert agent gjennomgikk autentisering, HELE autorisasjonslaget på
|
||||||
|
tvers av 16 routere, input-validering, filopplasting, CORS/nettverk og
|
||||||
|
hemmeligheter i git-historikken. Konklusjon: autorisasjons-/IDOR-laget er
|
||||||
|
uvanlig grundig og konsekvent (ingen bekreftet IDOR i noe skrive-
|
||||||
|
endepunkt) — de reelle funnene var manglende rate limiting (HØY),
|
||||||
|
manglende sikkerhetshoder (LAV-MIDDELS), inkonsekvent input-validering
|
||||||
|
(LAV), og én liten informasjonslekkasje i ett BBB-endepunkt
|
||||||
|
(informativt). Bruker: "tett sikkerhetshullene først."
|
||||||
|
|
||||||
|
**Beslutning A — Rate limiting, i minnet, ikke Redis (ennå).** Ny
|
||||||
|
`app/rate_limit.py`: en enkel fast-vindu-teller (`RateLimiter`) brukt
|
||||||
|
enten som FastAPI-dependency (IP-basert, via `client_ip()` som stoler på
|
||||||
|
`X-Forwarded-For` — trygt KUN fordi appen ikke er nåbar unntatt gjennom
|
||||||
|
Caddy, samme tillitsmodell som `should_use_secure_cookies()`) eller kalt
|
||||||
|
direkte med en egendefinert nøkkel. Lagt til på fem `app/routers/auth.py`
|
||||||
|
-endepunkter: `request-link`/`login-password`/`2fa/email/request`
|
||||||
|
(IP-basert, 5-10 forsøk/10 min — ressursen er ikke entydig knyttet til én
|
||||||
|
konto), og `2fa/verify`/`2fa/setup/confirm` (nøkkel = pending-/innlogget
|
||||||
|
bruker-ID, 8 forsøk/5 min — her ER ressursen én bestemt kontos kode, en
|
||||||
|
angriper med en gyldig pending-sesjon kunne ellers omgått IP-basert
|
||||||
|
begrensning ved å bytte IP). Trygt i minnet KUN fordi `teecup_api` kjører
|
||||||
|
som ÉN uvicorn-prosess (ingen `--workers`-flagg) — samme kjente
|
||||||
|
begrensning som andre in-memory-cacher i appen (se punkt 2 i listen
|
||||||
|
under). Ved fremtidig skalering til flere workers/containere MÅ dette
|
||||||
|
flyttes til Redis.
|
||||||
|
|
||||||
|
**Beslutning B — Sikkerhetshoder i Caddy, ikke Next.js-middleware.**
|
||||||
|
Lagt til i `/opt/teeoff/deploy/Caddyfile` sin `teecup.golf`-blokk (delt
|
||||||
|
fil med `teeoff.no`, KUN teecup.golf-blokken endret): `X-Frame-Options:
|
||||||
|
DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`,
|
||||||
|
`Strict-Transport-Security`, og en `Content-Security-Policy`. CSP-en er
|
||||||
|
bevisst IKKE en streng nonce-basert policy — appen bruker inline
|
||||||
|
`style`-attributter mange steder (React `style={{...}}`, hele
|
||||||
|
"clubhouse"-paletten) og Next.js sin hydrering kan trenge inline script,
|
||||||
|
så `'unsafe-inline'` er beholdt for script-src/style-src for å unngå å
|
||||||
|
knekke appen blindt. Verdien ligger i det som ER strammet inn:
|
||||||
|
`object-src`/`frame-ancestors`/`base-uri` blokkerer hele angrepsklasser,
|
||||||
|
og `connect-src`/`img-src` er begrenset til KUN Mapbox (slagmåling,
|
||||||
|
ADR-048) — en fremtidig XSS-bug kan ikke enkelt eksfiltrere data til en
|
||||||
|
vilkårlig tredjeparts-vert. Bekreftet i ekte nettleser mot produksjon:
|
||||||
|
ingen CSP-brudd i konsollen, et ekte Mapbox-kall (200 OK) fungerer
|
||||||
|
uendret.
|
||||||
|
|
||||||
|
**Reell driftsfallgruve funnet under utrulling, verdt å dokumentere:**
|
||||||
|
`docker exec teeoff_caddy caddy reload` (OG en direkte admin-API-`/load`
|
||||||
|
-PUT) rapporterte begge suksess uten feil, men endringen slo likevel
|
||||||
|
ALDRI igjennom — `md5sum` av filen inne i containeren avvek fra filen på
|
||||||
|
verten. Rotårsak: Docker sin bind-mount av EN ENKELT FIL (`./deploy/
|
||||||
|
Caddyfile:/etc/caddy/Caddyfile:ro`) er bundet til INODE-en filen hadde
|
||||||
|
ved containerens oppstart. Et skriveverktøy som lagrer atomisk (skriv til
|
||||||
|
midlertidig fil + `rename()`, vanlig og trygt mønster generelt) bytter ut
|
||||||
|
inode-en på verten — den kjørende containerens mount fortsetter da å
|
||||||
|
referere den GAMLE, nå frikoblede inode-en, usynlig for `reload`/admin-
|
||||||
|
API (som begge leser filen på nytt, men containerens FILSYSTEM-syn av
|
||||||
|
stien er allerede feil). Løsning: `docker restart teeoff_caddy` (ikke
|
||||||
|
bare `reload`) — dette er en STÅENDE fallgruve for enhver fremtidig
|
||||||
|
Caddyfile-endring, ikke unikt for denne runden. Kort, ufarlig avbrudd for
|
||||||
|
BEGGE sidene (teeoff.no og teecup.golf) ved restart, siden de deler
|
||||||
|
samme Caddy-container.
|
||||||
|
|
||||||
|
**Beslutning C — Inputvalidering, konsistens fremfor nye regler.** Ingen
|
||||||
|
nye valideringsprinsipper — kun manglende `max_length`/`ge`/`le` lagt til
|
||||||
|
der de manglet, etter EKSAKT samme mønster som allerede fantes andre
|
||||||
|
steder i samme fil (f.eks. `PlayerCreate.handicap_index` fikk samme
|
||||||
|
`ge=-10, le=54` som `auth.py` sin `ProfileUpdate.handicap_index` allerede
|
||||||
|
hadde). Én reell feil unngått underveis: `PersonalCourseTeeRatingIn.par`
|
||||||
|
ble først satt til `ge=3, le=6` (feilaktig antatt å være per-hull-par, som
|
||||||
|
`PersonalCourseHoleIn.par`) — DB-skjemaet (`020_personal_rounds.sql`,
|
||||||
|
ingen CHECK-constraint på den kolonnen) avslørte at det faktisk er
|
||||||
|
BANENS TOTALE par brukt i WHS-beregningen, rettet til `ge=54, le=90` før
|
||||||
|
utrulling. Endret: `rounds.py` (`PersonalCourseTeeRatingIn`/
|
||||||
|
`PersonalCourseTeeIn.name`/`PersonalCourseCreate.name`), `players.py`
|
||||||
|
(`PlayerCreate`/`PlayerUpdate`, begge — `handicap_index`/`mobile`/
|
||||||
|
`nickname`/`country`/`club`/`club_member_number`), `round_messages.py`
|
||||||
|
(`CommentIn.body`, delt/importert av `messaging.py` sine to bruksseder —
|
||||||
|
IKKE duplisert), samt tre `Form(default=None)`-felt (`round_messages.py`
|
||||||
|
+ to i `messaging.py`) som fikk `max_length=2000` (tekst) eller
|
||||||
|
`max_length=20` (invitasjonskode, som er 6 tegn generert, se
|
||||||
|
`tournaments.py._generate_join_code`).
|
||||||
|
|
||||||
|
**Beslutning D — BBB-endepunkt: autorisasjon flyttet FØR formatsjekk.**
|
||||||
|
`update_bbb_hole` i `rounds.py` kalte `_get_accessible_round_or_404`
|
||||||
|
ETTER å ha lest og validert `play_format`/`holes_planned` — en bruker
|
||||||
|
UTEN tilgang til en fremmed runde kunne dermed skille "runden finnes
|
||||||
|
ikke" (404) fra "runden finnes, feil format" (400) via feilmeldingen,
|
||||||
|
uten å oppnå noen skrivetilgang. Byttet rekkefølge: autorisasjon leses
|
||||||
|
FØRST, deretter format/hull-omfang. `round_row` kan ikke lenger være
|
||||||
|
`None` etter en vellykket autorisasjonssjekk, så den separate
|
||||||
|
null-sjekken er fjernet (var uansett aldri nåbar etter omorganiseringen).
|
||||||
|
|
||||||
|
**Scratch-verifisert** (tjuesjuende scratch-miljø denne økten): rate
|
||||||
|
limiting bekreftet å faktisk utløse 429 ved riktig terskel (10./11. forsøk
|
||||||
|
på login-password, 6. forsøk på request-link), gyldig/ugyldig
|
||||||
|
input-validering bekreftet begge veier (HCP over 54 avvist, gyldig HCP
|
||||||
|
akseptert, for lang mobil avvist), BBB-endepunktet bekreftet å returnere
|
||||||
|
403 NOT_AUTHORIZED (ikke lenger 400 format-lekkasje) for en bruker uten
|
||||||
|
tilgang, OG fortsatt 200 OK for den faktiske eieren (regresjonssjekk).
|
||||||
|
`teecup_db`s ACL bekreftet uendret før/etter, scratch-ressurser ryddet
|
||||||
|
opp fullstendig. CSP verifisert i ekte nettleser mot produksjon (se
|
||||||
|
Beslutning B).
|
||||||
|
|
||||||
|
**Bevisst utenfor omfang denne runden** (nevnt av agenten, ikke fulgt
|
||||||
|
opp): full linje-for-linje-gjennomgang av `individual_tournaments.py`/
|
||||||
|
`tournaments.py`/`order_of_merit.py`/`courses.py` (kun stikkprøvd),
|
||||||
|
Web Push-abonnement-kapring-teori (praktisk risiko vurdert svært lav,
|
||||||
|
push-endepunkt-URL-er er ugjettbare), noen faktisk penetrasjonstest (kun
|
||||||
|
statisk kodegjennomgang).
|
||||||
|
|
||||||
|
## ADR-051: 13-årsgrense for kontoregistrering + presentasjonstekst-korrigeringer
|
||||||
|
|
||||||
|
Brukeren, 2026-08-10, etter å ha lest PDF-vedlegget "Barns personvern i
|
||||||
|
golfapp" (en Gemini-samtale om GDPR/personvernkrav for mindreårige i en
|
||||||
|
GPS+UGC-app): "Vi må gjøre noe med de som er barn. For det første:
|
||||||
|
Tillatt aldri de som er under 13 år å registrere seg i appen." Samtidig,
|
||||||
|
to relaterte presentasjonskorrigeringer: et annet PDF-vedlegg ("Installere
|
||||||
|
PWA fra Chrome på iPhone") viste at installasjonsteksten feilaktig
|
||||||
|
hevdet at PWA-installasjon KREVER Safari på iOS -- siden iOS 16.4 gjelder
|
||||||
|
ikke det lenger. Og: "Jeg tror ikke vi skal nevne golfklubber i
|
||||||
|
beskrivelsen av TeeCup," pluss et ønske om å fremheve at
|
||||||
|
turneringsadministrasjon/-presentasjon fungerer like godt på PC.
|
||||||
|
|
||||||
|
**Beslutning A — 13-årsgrense håndheves i `PATCH /auth/profile`, ikke i
|
||||||
|
et eget registreringssteg.** Siden `profile_complete` (fødselsdato er ett
|
||||||
|
av de obligatoriske feltene) allerede er den ENESTE veien forbi
|
||||||
|
`/velkommen`-sperren (ADR-049) og inn i resten av appen, er dette det
|
||||||
|
naturlige, allerede-eksisterende knutepunktet -- en under-13-åring kan
|
||||||
|
rett og slett aldri fullføre profilen sin, og kommer dermed aldri forbi
|
||||||
|
`/velkommen`s låste hurtighandlinger. Ingen ny tabell/kolonne, ingen nytt
|
||||||
|
flagg. Håndhevet SERVER-SIDE i `app/routers/auth.py` sin `update_profile`
|
||||||
|
(alder beregnet fra `body.birth_date`, avvist med `400 UNDER_MINIMUM_AGE`
|
||||||
|
hvis under 13 -- eksakt dagsgrense, ikke bare årstall), PLUSS
|
||||||
|
klientside i BEGGE skjemaer som setter fødselsdato
|
||||||
|
(`ProfileOnboarding` og `ProfileSection` i `account-settings.tsx`, delt
|
||||||
|
`computeAge()`-hjelpefunksjon) for umiddelbar tilbakemelding før
|
||||||
|
serveren i det hele tatt kontaktes.
|
||||||
|
|
||||||
|
**Bevisst IKKE utvidet til `player`-tabellen** (organisasjonens
|
||||||
|
spillerpool, `players.py`/`registration.py`). Dette er en annen
|
||||||
|
datamodell: en `player`-rad representerer en ROSTER-oppføring en
|
||||||
|
ARRANGØR/klubb legger inn om en person, IKKE nødvendigvis en person med
|
||||||
|
egen app-innlogging (`player.user_id` er nullable, akkurat denne
|
||||||
|
frikoblingen er selve poenget). Brukerens instruks var "de som er under
|
||||||
|
13 år å registrere seg" -- altså SELV opprette en app-konto, ikke at en
|
||||||
|
voksen arrangør registrerer et barn som deltaker i en turnering (det
|
||||||
|
siste er faktisk PDF-vedleggets EGEN anbefalte, tryggere løsning for
|
||||||
|
yngre spillere -- "sub-accounts" ført av en voksen, se PDF-ens punkt 2
|
||||||
|
under "Anbefalte løsninger"). Å blokkere `player`-tabellen ville brutt
|
||||||
|
akkurat den mekanismen.
|
||||||
|
|
||||||
|
**Beslutning B — PWA-installasjonstekst skiller nå Safari/Chrome på
|
||||||
|
iOS.** `install-prompt.tsx` sin `isIOS()`-sjekk fanger ALLE iOS-
|
||||||
|
nettlesere (deler WebKit-motor), men Del-ikonets PLASSERING er ulik
|
||||||
|
(Safari: nederst på skjermen. Chrome: oppe til høyre i adressefeltet) --
|
||||||
|
generisk "i Safari"-tekst var dermed direkte feil for en Chrome-bruker,
|
||||||
|
ikke bare unøyaktig. Ny `iosBrowser()`-funksjon skiller på `CriOS`
|
||||||
|
(Chrome) i user agent-strengen (vanlig "Chrome"-sniffing ville feilaktig
|
||||||
|
også truffet Safari, som deler samme motor). Ukjente/andre iOS-
|
||||||
|
nettlesere får en nøytral "i nettleseren din"-tekst i stedet for å gjette.
|
||||||
|
|
||||||
|
**Beslutning C — presentasjonstekst korrigert i to filer.**
|
||||||
|
`teecup-beskrivelse.md` OG `velkommen.tsx` sin "Hva er TeeCup?"-seksjon
|
||||||
|
(sistnevnte var bevisst skrevet om fra førstnevnte, ikke limt inn rått,
|
||||||
|
se ADR-049) -- begge fikk "golfklubber" fjernet fra målgruppe-
|
||||||
|
beskrivelsen (både "hva det er i dag" og "hva det skal bli"-seksjonene
|
||||||
|
i beskrivelses-dokumentet), og et nytt punkt lagt til om at
|
||||||
|
turneringsadministrasjon/-presentasjon fungerer minst like godt på PC
|
||||||
|
som på telefon. "Klubbhus-stemning" (personlighet/tone-beskrivelsen,
|
||||||
|
navnet på selve designretningen) er UENDRET -- det er ikke en
|
||||||
|
målgruppe-påstand, kun en stemningsbeskrivelse.
|
||||||
|
|
||||||
|
**Scratch-verifisert** (tjueåttende scratch-miljø denne økten): eksakt
|
||||||
|
dagsgrense bekreftet med tre testtilfeller mot ekte API -- 10-åring
|
||||||
|
avvist (400 UNDER_MINIMUM_AGE), nøyaktig 13 år i dag akseptert (200,
|
||||||
|
`profile_complete: true`), én dag under 13 år avvist (400) -- samme
|
||||||
|
grensesnitt-endepunkt, kun fødselsdatoen endret mellom kallene. Bekreftet
|
||||||
|
i ekte nettleser: klientside-feilmeldingen vises umiddelbart med rød
|
||||||
|
kant ved en 2018-fødselsdato, "Fortsett"-knappen forblir deaktivert.
|
||||||
|
Presentasjonsteksten på `/velkommen` bekreftet uten "golfklubber" og med
|
||||||
|
det nye PC-punktet. `tsc --noEmit`/`py_compile` rene, ingen konsollfeil.
|
||||||
|
`teecup_db`s ACL bekreftet uendret, scratch-ressurser ryddet opp
|
||||||
|
fullstendig.
|
||||||
|
|
||||||
|
**Bevisst utenfor omfang denne runden** (drøftet med bruker, avtalt som
|
||||||
|
egen fremtidig runde): rapportering/blokkering/moderering av
|
||||||
|
brukergenerert innhold (bilder/kommentarer) og "privat som standard for
|
||||||
|
mindreårige"-personvern for GPS-/rundedeling -- begge reelle,
|
||||||
|
substansielle krav fra PDF-vedlegget for at appen skal godkjennes i App
|
||||||
|
Store/Google Play med UGC+GPS, men et eget, avgrenset prosjekt (admin-
|
||||||
|
panel, databasefiltrering, 24-timers responstid), ikke noe som hører
|
||||||
|
hjemme i samme runde som en enkel aldersgrense.
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
||||||
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på
|
1. **Sesjons-secret:** TeeOff lar `PUBLIC_SESSION_SECRET` falle tilbake på
|
||||||
|
|
|
||||||
317
CHANGELOG.md
317
CHANGELOG.md
|
|
@ -9571,6 +9571,317 @@ Neste steg:
|
||||||
beskriver. Scratch-miljøet (DB, rolle, MinIO, API- og
|
beskriver. Scratch-miljøet (DB, rolle, MinIO, API- og
|
||||||
frontend-container/-images) ryddet opp fullstendig etterpå.
|
frontend-container/-images) ryddet opp fullstendig etterpå.
|
||||||
|
|
||||||
**Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse
|
**Rullet ut** samme økt, bruker bekreftet eksplisitt ("Ja") — migrasjon
|
||||||
før migrasjon/deploy mot ekte `teecup_db`/`teecup_api`/
|
061 kjørt mot ekte `teecup_db`, `teecup_api` og `teecup_frontend`
|
||||||
`teecup_frontend`, samme rutine som punkt 48.
|
bygget/restartet rent, `teecup_db`s ACL bekreftet uendret før/etter,
|
||||||
|
verifisert live på `teecup.golf` uten konsollfeil.
|
||||||
|
|
||||||
|
54. **Ball-steget viser nå alltid kartet med løpende (sanntids) posisjon
|
||||||
|
og avstand, og allerede målte slag viser satellittfoto i listen —
|
||||||
|
2026-08-08, umiddelbar brukeroppfølging etter punkt 53.** Bruker,
|
||||||
|
etter å ha sett skjermbilder av den nye funksjonen: "Jeg får ikke sett
|
||||||
|
slagene jeg allerede har målt... jeg ønsker å kunne se på et
|
||||||
|
satelittfoto hvor jeg har slått hvert slag. Det andre er at selv om
|
||||||
|
jeg velger 'min posisjon nå', så skal jeg se start og slutt på et
|
||||||
|
satelittfoto... I alle sammenhenger... ønsker jeg at jeg skal se
|
||||||
|
lengden så langt i sanntid mens jeg nærmer meg ballen." Se
|
||||||
|
"Tillegg 2026-08-08 (del 2)" i ARCHITECTURE_DECISIONS.md (ADR-048) for
|
||||||
|
full arkitektur-begrunnelse, inkl. det bevisste, avgrensede unntaket
|
||||||
|
fra "ingen løpende watchPosition"-prinsippet.
|
||||||
|
|
||||||
|
**Endringer, kun frontend (ingen migrasjon, `ShotOut` hadde allerede
|
||||||
|
alle koordinatene):**
|
||||||
|
- `map-point-picker.tsx`: `onConfirm` tar nå `(lngLat, method)` i
|
||||||
|
stedet for bare `(lngLat)`. Når `referencePoint` er satt (ball-
|
||||||
|
steget) startes `navigator.geolocation.watchPosition()` ved mount,
|
||||||
|
med en egen blå "du er her"-markør som oppdateres løpende og et
|
||||||
|
stort sanntids-avstand-tall ("AVSTAND SÅ LANGT") nederst på kartet,
|
||||||
|
beregnet med `haversineMeters(referencePoint, livePosition)`.
|
||||||
|
Footeren viser nå TO knapper når `referencePoint` er satt: "Bekreft
|
||||||
|
ballens posisjon" (trykket punkt, kun aktiv etter tap) og "Jeg er
|
||||||
|
ved ballen nå" (siste sporede posisjon, aktiv så snart første
|
||||||
|
GPS-fix er mottatt). `watchPosition` ryddes opp (`clearWatch`) ved
|
||||||
|
avmontering.
|
||||||
|
- `shot-measurement-sheet.tsx`: `end_map`-steget fjernet igjen (varte
|
||||||
|
kun én runde) — "end"-steget rendrer nå ALLTID `MapPointPicker`
|
||||||
|
direkte med `referencePoint={startPoint}`, ingen egen GPS/kart-valg-
|
||||||
|
skjerm lenger (den valget ligger nå inne i selve kartkomponentens
|
||||||
|
footer, se over). `measureEndPoint()`/`endStatus` fjernet (ikke
|
||||||
|
lenger i bruk). Egen duplikat Haversine-implementasjon
|
||||||
|
(`previewDistance`) erstattet med `haversineMeters` fra
|
||||||
|
`frontend/lib/geo.ts` (som alt fantes, men var ubrukt inntil nå) —
|
||||||
|
ren opprydding, ingen atferdsendring.
|
||||||
|
- `round-detail.tsx`: `ShotRecord`-typen utvidet med
|
||||||
|
`start_lat`/`start_lng`/`end_lat`/`end_lng` (allerede returnert av
|
||||||
|
API-et, kun frontend-typen manglet dem). `ShotList` viser nå et
|
||||||
|
160×160 satellitt-thumbnail per slag (samme offentlige token og
|
||||||
|
`pin-s-a`/`pin-s-b`-fargekonvensjon som forhåndsvisningen), bygget
|
||||||
|
client-side direkte fra de lagrede koordinatene — løser "jeg burde
|
||||||
|
jo se slaget selv, selv om det ikke er delt" sitt neste lag: ikke
|
||||||
|
bare klubbe/avstand som tekst, men HVOR slaget faktisk ble slått.
|
||||||
|
|
||||||
|
`tsc --noEmit` rent (ingen backend-endring denne runden).
|
||||||
|
|
||||||
|
**Scratch-verifisert** (tjuefjerde scratch-miljø denne økten, ingen
|
||||||
|
ny migrasjon å teste denne runden — samme 61 migrasjoner + `teecup_db`
|
||||||
|
ACL-forsiktighet som før): full nettleser-gjennomgang via Chrome
|
||||||
|
DevTools MCP med BÅDE `getCurrentPosition`- og `watchPosition`
|
||||||
|
monkey-patchet (sistnevnte simulerer en spiller som beveger seg fra
|
||||||
|
start mot ballen over ~5 sekunder via et `setInterval`). Bekreftet:
|
||||||
|
(1) sanntids-avstanden i kartet regner nøyaktig samme tall som en
|
||||||
|
uavhengig Python Haversine-kontroll (315,04 m); (2) "Jeg er ved ballen
|
||||||
|
nå" bruker siste sporede posisjon og lagret korrekt med
|
||||||
|
`end_method=gps` i databasen; (3) tap på kartet plasserer en grønn
|
||||||
|
markør og aktiverer "Bekreft ballens posisjon"; (4) en reell
|
||||||
|
grensesnitt-verifisering av EKSISTERENDE feilhåndtering (fra punkt 50)
|
||||||
|
skjedde underveis helt av seg selv: et tap som (ved et scratch-
|
||||||
|
test-uhell) landet ~0 m fra referansepunktet ble korrekt avvist av
|
||||||
|
backendens `distance_meters > 0`-sjekk (422), og arket viste riktig
|
||||||
|
feilmelding i stedet for å late som suksess — bekrefter at den
|
||||||
|
beskyttelsen fortsatt virker uendret gjennom hele denne
|
||||||
|
ombyggingen; (5) slag-listens nye satellitt-thumbnails lastet korrekt
|
||||||
|
for begge tidligere lagrede slag. Ingen uventede konsollfeil (kun
|
||||||
|
kjente scratch-miljø-artefakter: WebGL-fallback-advarsel, en
|
||||||
|
irrelevant WebSocket-tidsavbrudd, og den FORVENTEDE 422-en fra punkt
|
||||||
|
4). Scratch-miljøet (DB, rolle, MinIO, API- og frontend-container/
|
||||||
|
-images) ryddet opp fullstendig etterpå, `teecup_db`s ACL bekreftet
|
||||||
|
uendret.
|
||||||
|
|
||||||
|
**Rullet ut** samme økt, bruker bekreftet eksplisitt ("Ja") —
|
||||||
|
`teecup_frontend` bygget/restartet rent (ingen migrasjon eller
|
||||||
|
backend-endring denne runden, `teecup_api` uendret), verifisert live
|
||||||
|
på `teecup.golf` uten konsollfeil.
|
||||||
|
|
||||||
|
55. **Ny landingsside `/velkommen` for innlogget-men-ikke-fullført-profil
|
||||||
|
— 2026-08-09, eksplisitt brukerønske (ADR-049, se
|
||||||
|
ARCHITECTURE_DECISIONS.md for full begrunnelse og drøfting).** Bruker:
|
||||||
|
"Skjemaet med personlig informasjon vises for tidlig for ikke
|
||||||
|
registrerte spillere etter at de logger seg inn... Jeg tror det beste
|
||||||
|
er at man kommer til en side med oppfordring til å installere som app
|
||||||
|
(som vanlig) og med deaktiverte knapper for runde, turneringen og bli
|
||||||
|
med med kode. Trykker man på noen av disse skal man få beskjed om at
|
||||||
|
personlig informasjon må fylles ut først, med lenke til skjemaet."
|
||||||
|
Midtveis presisert: "Alle knappene bør egentlig være der, deaktivert.
|
||||||
|
bortsett fra til profilen" — dvs. HELE den vanlige bunn-navigasjonen
|
||||||
|
skal vises, ikke bare de tre hurtighandlingene.
|
||||||
|
|
||||||
|
**Rotårsak til det opprinnelige problemet:** tre steder rutet en
|
||||||
|
innlogget-men-ikke-fullført-profil-bruker RETT til `/account` sitt
|
||||||
|
påtvungne skjema uten noen kontekst: `app/page.tsx` (root-redirect),
|
||||||
|
`app/logg-inn/page.tsx` (post-innlogging-redirect), og
|
||||||
|
`dashboard.tsx` sin egen klient-side `profile_complete`-vakt
|
||||||
|
(`loadMe()`, fyres hvis en bruker skulle lande direkte på
|
||||||
|
`/dashboard`, f.eks. via `verify-form.tsx` som ALLTID sender dit
|
||||||
|
etter innlogging uansett profil-status).
|
||||||
|
|
||||||
|
**Bygget:**
|
||||||
|
- Ny `frontend/app/velkommen/page.tsx` (server-komponent, samme
|
||||||
|
autentiserings-/redirect-mønster som `/logg-inn/page.tsx` --
|
||||||
|
`redirect()`-kall bevisst holdt UTENFOR try/catch, samme fallgruve
|
||||||
|
som ble funnet og fikset i `/logg-inn/page.tsx` 2026-08-08 unngås
|
||||||
|
her fra start) + `frontend/components/teecup/velkommen.tsx`
|
||||||
|
(klient-komponent). Alle tre stedene over pekt om til `/velkommen`
|
||||||
|
i stedet for `/account`. `/velkommen` selv redirecter videre til
|
||||||
|
`/dashboard` (komplett profil) eller `/logg-inn` (ikke innlogget)
|
||||||
|
-- kan ikke nås "feil" via direkte URL.
|
||||||
|
- Siden gjenbruker eksisterende, allerede etablerte komponenter
|
||||||
|
uendret: `InstallPrompt` (samme PWA-oppfordring som dashbordet),
|
||||||
|
`TeeCupWordmark`, og samme "clubhouse"-palett-tokens som
|
||||||
|
`/logg-inn`/dashbordet (reskinnet 2026-08-07/08). Personlig
|
||||||
|
hilsen bruker `first_name ?? display_name`, samme navneformat-regel
|
||||||
|
som dashbordets hilsen (CLAUDE.md).
|
||||||
|
- Tre synlige, LÅSTE hurtighandlinger ("Ny runde"/"Ny turnering"/"Bli
|
||||||
|
med med kode" -- hengelås-ikon, `aria-disabled`, IKKE HTML
|
||||||
|
`disabled` siden de fortsatt skal være klikkbare for å forklare
|
||||||
|
hvorfor de er låst).
|
||||||
|
- `frontend/components/teecup/bottom-nav.tsx` utvidet med valgfrie
|
||||||
|
`disabledHrefs`/`onDisabledClick`-props (bakoverkompatibelt --
|
||||||
|
andre 6 sider som allerede bruker `BottomNav` uendret, ingen prop
|
||||||
|
sendt). Når en fane er i `disabledHrefs`, rendres den som en
|
||||||
|
`<button aria-disabled>` i stedet for `<Link>` -- `/velkommen`
|
||||||
|
viser dermed HELE den vanlige bunn-navigasjonen (Hjem/Runder/
|
||||||
|
Turneringer/Profil/Mer), med alle unntatt "Profil" låst, per
|
||||||
|
brukerens presisering midtveis.
|
||||||
|
- Trykk på EN HVILKEN SOM HELST låst kontroll (hurtighandling ELLER
|
||||||
|
bunnfane) viser samme delte påminnelse (`role="alert"`, fast
|
||||||
|
posisjonert rett over bunn-navigasjonen slik at den er synlig
|
||||||
|
uansett hvilken av de to gruppene som trigget den) med lenke videre
|
||||||
|
til `/account`.
|
||||||
|
- Presentasjons-seksjonen nederst er hentet fra `teecup-beskrivelse.md`,
|
||||||
|
skrevet om til kort UI-tekst (fire punkter: turneringsformater, live
|
||||||
|
scoreføring, WHS-handicap, sosialt) -- ikke limt inn rått.
|
||||||
|
|
||||||
|
`tsc --noEmit` rent. Ingen migrasjon eller backend-endring.
|
||||||
|
|
||||||
|
**Scratch-verifisert** (tjuefemte scratch-miljø denne økten): full
|
||||||
|
nettleser-gjennomgang av HELE kjeden med en HELT NY bruker (aldri sett
|
||||||
|
før, ikke forhåndsopprettet i databasen som tidligere scratch-økter)
|
||||||
|
-- registrerte e-post, verifiserte magic-link, landet automatisk på
|
||||||
|
`/velkommen` (bekrefter at BÅDE `verify-form.tsx` sin `/dashboard`-
|
||||||
|
redirect OG dashbordets egen vakt samvirker riktig -- vakten er den
|
||||||
|
som faktisk avgjør endestasjonen). Bekreftet: hilsen viser riktig navn,
|
||||||
|
`InstallPrompt` vises, hurtighandlinger viser hengelås og er
|
||||||
|
ikke-navigerende, bunn-navigasjonen viser alle fem faner med fire
|
||||||
|
låst og "Profil" aktiv, trykk på en låst hurtighandling viser
|
||||||
|
påminnelsen med korrekt lenke. Fulgte "Fyll ut nå" til `/account`,
|
||||||
|
fylte ut skjemaet ende-til-ende, endte korrekt på `/dashboard` med
|
||||||
|
riktig personlig hilsen ("God dag, Ny"). Ingen konsollfeil gjennom
|
||||||
|
hele kjeden. Scratch-miljøet (DB, rolle, MinIO, API- og
|
||||||
|
frontend-container/-images) ryddet opp fullstendig etterpå,
|
||||||
|
`teecup_db`s ACL bekreftet uendret.
|
||||||
|
|
||||||
|
**Rullet ut** samme økt, bruker bekreftet eksplisitt ("ja") —
|
||||||
|
`teecup_frontend` bygget/restartet rent (`teecup_api` uendret, ingen
|
||||||
|
migrasjon), verifisert live på `teecup.golf` uten konsollfeil.
|
||||||
|
|
||||||
|
56. **Fjernet "Bekreft ballens posisjon" (trykk-på-kartet for ballens
|
||||||
|
posisjon) igjen — 2026-08-09, samme dag som punkt 54/55, etter faktisk
|
||||||
|
bruk på ekte bane.** Bruker sendte et ekte skjermbilde fra telefonen
|
||||||
|
(utslags- og live-markør nesten overlappende, "AVSTAND SÅ LANGT 1 m")
|
||||||
|
og spurte om forskjellen mellom de to bekreftelsesknappene. Etter
|
||||||
|
forklaring: "bekreft ballens posisjon er unødvendig." Avklart via
|
||||||
|
spørsmål (siden dette reverserer noe brukeren selv ba om bare timer
|
||||||
|
tidligere, jf. CLAUDE.md "spør heller enn å gjette" ved usikkerhet om
|
||||||
|
omfang): fjern trykk-alternativet HELT, behold kartet (referansepunkt
|
||||||
|
+ sanntidsposisjon/-avstand), ballens posisjon bekreftes nå
|
||||||
|
UTELUKKENDE med "Jeg er ved ballen nå" (sporet GPS).
|
||||||
|
|
||||||
|
**Endringer, kun `map-point-picker.tsx`:**
|
||||||
|
- `map.on("click", ...)`-registreringen (trykk-for-å-plassere-markør)
|
||||||
|
er nå betinget på `!referencePoint` -- kjører fortsatt uendret på
|
||||||
|
start-steget, aldri lenger på ball-steget.
|
||||||
|
- "Trykk der ballen ligger"-banneret fjernet for ball-steget (ingen
|
||||||
|
trykk-handling å instruere om lenger); start-stegets "Trykk på
|
||||||
|
kartet for å plassere punktet" uendret.
|
||||||
|
- Footeren på ball-steget viser nå kun ÉN knapp ("Jeg er ved ballen
|
||||||
|
nå", primærstil) i stedet for to stablede knapper.
|
||||||
|
- Ingen backend-/migrasjonsendring: `end_method`-kolonnen og
|
||||||
|
`Literal["gps", "map_tap"]`-typen beholdes uendret (historiske
|
||||||
|
`map_tap`-rader fra den korte perioden funksjonen var live skal
|
||||||
|
fortsatt leses/vises korrekt -- kun hvordan NYE slag kan opprettes
|
||||||
|
er endret).
|
||||||
|
|
||||||
|
`tsc --noEmit` rent.
|
||||||
|
|
||||||
|
**Scratch-verifisert** (tjuesjette scratch-miljø denne økten, samme
|
||||||
|
forsiktighetsrutine, `teecup_db`s ACL bekreftet uendret): full
|
||||||
|
nettleser-gjennomgang med `getCurrentPosition`/`watchPosition`
|
||||||
|
monkey-patchet. Bekreftet at ball-steget nå viser kartet direkte med
|
||||||
|
KUN "Jeg er ved ballen nå" i footeren (ingen "Bekreft ballens
|
||||||
|
posisjon"), at et trykk midt på kartet ikke lenger gjør noe (ingen ny
|
||||||
|
markør, ingen banner om å trykke), og at "Jeg er ved ballen nå"
|
||||||
|
fortsatt lagrer korrekt -- `start_method=gps, end_method=gps`
|
||||||
|
bekreftet direkte i databasen etter en full måle-runde (start via GPS,
|
||||||
|
kølle, lagring). Ingen konsollfeil. Scratch-miljøet ryddet opp
|
||||||
|
fullstendig.
|
||||||
|
|
||||||
|
**Rullet ut** samme økt, bruker bekreftet eksplisitt ("ja") —
|
||||||
|
`teecup_frontend` bygget/restartet rent (`teecup_api` uendret),
|
||||||
|
verifisert live på `teecup.golf` uten konsollfeil.
|
||||||
|
|
||||||
|
57. **Sikkerhetsgjennomgang + fiks av alle funn — 2026-08-09/10, bruker:
|
||||||
|
"sjekk alle felter... sjekk alle URL-er. Er alt sikret godt nok? Lar
|
||||||
|
siden/appen seg hacke?", deretter "tett sikkerhetshullene først."**
|
||||||
|
Full gjennomgang (dedikert agent) av autentisering, autorisasjon på
|
||||||
|
tvers av alle 16 routere, input-validering, filopplasting, CORS/
|
||||||
|
nettverk, hemmeligheter i git. Se ADR-050 i ARCHITECTURE_DECISIONS.md
|
||||||
|
for full begrunnelse. Konklusjon: autorisasjonslaget er uvanlig
|
||||||
|
solid (ingen bekreftet IDOR), men fire reelle hull ble funnet og
|
||||||
|
fikset:
|
||||||
|
|
||||||
|
1. **Rate limiting** (HØY) lagt til på fem auth-endepunkter (ny
|
||||||
|
`app/rate_limit.py`) — ingen fantes fra før, passord/2FA-koder
|
||||||
|
kunne i praksis brute-forces.
|
||||||
|
2. **Sikkerhetshoder** (LAV-MIDDELS) lagt til i Caddy for
|
||||||
|
teecup.golf: CSP, X-Frame-Options, X-Content-Type-Options,
|
||||||
|
Referrer-Policy, HSTS.
|
||||||
|
3. **Input-validering** (LAV) — manglende `max_length`/`ge`/`le` lagt
|
||||||
|
til konsekvent i `rounds.py`/`players.py`/`round_messages.py`/
|
||||||
|
`messaging.py`, etter mønster som allerede fantes andre steder i
|
||||||
|
samme filer.
|
||||||
|
4. **BBB-endepunkt informasjonslekkasje** (informativt) — autorisasjon
|
||||||
|
flyttet til FØR formatsjekk i `update_bbb_hole`.
|
||||||
|
|
||||||
|
**Reell driftsfallgruve funnet og løst underveis:** `caddy reload`
|
||||||
|
(og en direkte admin-API-PUT) rapporterte suksess, men Caddyfile-
|
||||||
|
endringen slo aldri igjennom — `teeoff_caddy`s bind-mount av
|
||||||
|
Caddyfile-EN ENKELT FIL var bundet til en nå frikoblet inode etter en
|
||||||
|
atomisk skriving (skriv+omdøp) på verten. Løst med `docker restart
|
||||||
|
teeoff_caddy` (ikke bare reload) — se ADR-050 for full forklaring,
|
||||||
|
dette er en STÅENDE fallgruve for enhver fremtidig Caddyfile-endring.
|
||||||
|
|
||||||
|
`python3 -m py_compile` rent på alle endrede filer.
|
||||||
|
|
||||||
|
**Scratch-verifisert** (tjuesjuende scratch-miljø denne økten, samme
|
||||||
|
`teecup_db`-ACL-forsiktighet): rate limiting bekreftet å faktisk
|
||||||
|
utløse 429 ved riktig terskel på BÅDE `login-password` (10 tillatt,
|
||||||
|
11. avvist) og `request-link` (5 tillatt, 6. avvist); input-
|
||||||
|
validering bekreftet begge veier (HCP=99 avvist, HCP=18.4 akseptert,
|
||||||
|
50-tegns mobilnummer avvist); BBB-endepunktet bekreftet å returnere
|
||||||
|
403 NOT_AUTHORIZED (ikke lenger en 400 format-lekkasje) for en bruker
|
||||||
|
uten tilgang til en fremmed runde, OG fortsatt 200 OK for den
|
||||||
|
faktiske eieren på en ekte BBB-runde (regresjonssjekk). CSP-en
|
||||||
|
verifisert direkte mot PRODUKSJON i ekte nettleser (ikke scratch,
|
||||||
|
siden Caddy-konfigen er delt infrastruktur som ikke er en del av
|
||||||
|
scratch-oppsettet): ingen CSP-brudd i konsollen, et ekte Mapbox-kall
|
||||||
|
med gyldig token ga 200 OK, login-siden rendret visuelt korrekt (inline
|
||||||
|
styles uendret). `teecup_db`s ACL bekreftet uendret før/etter,
|
||||||
|
scratch-ressurser ryddet opp fullstendig.
|
||||||
|
|
||||||
|
**Rullet ut** — Caddy-delen ble rullet ut/verifisert direkte mot
|
||||||
|
produksjon underveis (se over). `teecup_api`-delen (rate limiting,
|
||||||
|
validering, BBB-fiks) bygget/restartet etter eksplisitt
|
||||||
|
brukerbekreftelse ("ja takk"), verifisert LIVE: `POST /auth/
|
||||||
|
request-link` mot ekte `teecup.golf` ga 200 på de fem første
|
||||||
|
forsøkene og 429 på det sjette (nøyaktig terskelen satt i koden),
|
||||||
|
ingen konsollfeil i ekte nettleser etterpå.
|
||||||
|
|
||||||
|
58. **13-årsgrense for kontoregistrering + to presentasjonstekst-
|
||||||
|
korrigeringer — 2026-08-10, bruker etter å ha lest to PDF-vedlegg.**
|
||||||
|
Se ADR-051 i ARCHITECTURE_DECISIONS.md for full begrunnelse og
|
||||||
|
drøfting av anbefalingene i "Barns personvern i golfapp"-PDF-en.
|
||||||
|
|
||||||
|
**Endringer:**
|
||||||
|
- `app/routers/auth.py`: `update_profile` avviser nå `PATCH /auth/
|
||||||
|
profile` med `400 UNDER_MINIMUM_AGE` hvis `birth_date` innebærer
|
||||||
|
under 13 år (eksakt dagsberegning). Dette er den ENESTE veien inn i
|
||||||
|
appen (profil må være komplett for å komme forbi `/velkommen`,
|
||||||
|
ADR-049), så dette ene stedet håndhever grensen for hele appen.
|
||||||
|
Bevisst IKKE utvidet til `player`-tabellen (organisasjonens
|
||||||
|
roster/CRM, `players.py`/`registration.py`) -- en `player`-rad er
|
||||||
|
en arrangørs registrering AV en person, ikke personen som
|
||||||
|
registrerer SEG SELV, og er selve PDF-ens egen anbefalte, tryggere
|
||||||
|
løsning for yngre spillere.
|
||||||
|
- `account-settings.tsx`: ny delt `computeAge()`-hjelpefunksjon,
|
||||||
|
brukt i BÅDE `ProfileOnboarding` (førstegangs-utfylling) og
|
||||||
|
`ProfileSection` (senere redigering under Konto) for umiddelbar
|
||||||
|
rød feilmelding + deaktivert lagre-knapp -- før serveren i det
|
||||||
|
hele tatt kontaktes.
|
||||||
|
- `install-prompt.tsx`: ny `iosBrowser()` skiller Safari fra Chrome
|
||||||
|
på iOS (`CriOS` i user agent) -- installasjonsteksten hevdet
|
||||||
|
tidligere feilaktig at man MÅTTE til Safari, og pekte til "nederst
|
||||||
|
i Safari" uansett nettleser. Siden iOS 16.4 kan Chrome installere
|
||||||
|
PWA-er direkte, med Del-ikonet et ANNET sted (oppe til høyre i
|
||||||
|
adressefeltet, ikke nederst). Ukjente iOS-nettlesere får nå en
|
||||||
|
nøytral "i nettleseren din"-tekst i stedet for en gjetning.
|
||||||
|
- `teecup-beskrivelse.md` + `velkommen.tsx`: "golfklubber" fjernet
|
||||||
|
fra målgruppe-beskrivelsen (to steder i beskrivelses-dokumentet,
|
||||||
|
ett i `/velkommen`s "Hva er TeeCup?"), nytt punkt lagt til om at
|
||||||
|
turneringsadministrasjon/-presentasjon fungerer minst like godt på
|
||||||
|
PC som på telefon. "Klubbhus-stemning" (design-retningens navn,
|
||||||
|
ren tone-beskrivelse) er bevisst uendret.
|
||||||
|
|
||||||
|
`tsc --noEmit` og `python3 -m py_compile` begge rene.
|
||||||
|
|
||||||
|
**Scratch-verifisert** (tjueåttende scratch-miljø denne økten, samme
|
||||||
|
`teecup_db`-ACL-forsiktighet): eksakt dagsgrense bekreftet med tre
|
||||||
|
API-kall (10-åring avvist, nøyaktig 13 år i dag akseptert med
|
||||||
|
`profile_complete: true`, én dag under 13 avvist). Ekte nettleser:
|
||||||
|
klientside rød feilmelding + deaktivert knapp bekreftet ved
|
||||||
|
2018-fødselsdato, `/velkommen`s presentasjonstekst bekreftet uten
|
||||||
|
"golfklubber" og med det nye PC-punktet. Ingen konsollfeil. Scratch-
|
||||||
|
miljøet (DB, rolle, MinIO, API- og frontend-container/-images) ryddet
|
||||||
|
opp fullstendig, `teecup_db`s ACL bekreftet uendret.
|
||||||
|
|
||||||
|
**Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse før
|
||||||
|
`teecup_api`/`teecup_frontend` bygges/restartes mot ekte miljø.
|
||||||
|
|
|
||||||
|
|
@ -4423,9 +4423,17 @@ isolert forhåndsvisnings-container (ingen konsollfeil).
|
||||||
rent, `/logg-inn` → 200, `/` (dagens innlogging, uendret) → 200 fortsatt.
|
rent, `/logg-inn` → 200, `/` (dagens innlogging, uendret) → 200 fortsatt.
|
||||||
Nåbar på `teecup.golf/logg-inn` for sammenligning på ekte enhet.
|
Nåbar på `teecup.golf/logg-inn` for sammenligning på ekte enhet.
|
||||||
|
|
||||||
**Ingen beslutning tatt ennå om å faktisk ta i bruk denne retningen** —
|
**✅ BESLUTTET OG RULLET UT 2026-08-08:** bruker valgte denne retningen.
|
||||||
kun en parallell sammenligning. Ingen backend-kobling gjort — hvis denne
|
`teecup-auth.tsx` koblet til de ekte `/auth/request-link`/`/auth/verify-
|
||||||
retningen velges, må `teecup-auth.tsx` sine mockede API-kall kobles til
|
link`/`/auth/login-password`/`/public/tournaments/by-code`-endepunktene
|
||||||
de ekte `/auth/request-link`/`/auth/verify-link`/`/auth/login-password`/
|
(samme kontrakt som gamle `login-form.tsx` brukte). `/logg-inn` er nå den
|
||||||
`/public/tournaments/by-code`-endepunktene (samme kontrakt som
|
ekte innloggingssiden — `app/page.tsx` (root) er en tynn videresending
|
||||||
`login-form.tsx` allerede bruker) før den kan erstatte dagens `/`.
|
dit (beholdt for gamle bokmerker), gamle `LoginForm`/Forest Green-skjemaet
|
||||||
|
fjernet. Se CHANGELOG.md 2026-08-08 for byggelogg.
|
||||||
|
|
||||||
|
**Bevisst avgrenset omfang (bekreftet med bruker 2026-08-08):** kun
|
||||||
|
innlogging/auth-flyten fikk den nye "clubhouse"-paletten. Resten av appen
|
||||||
|
(dashbord, scorekort, runde-visning osv.) beholder dagens etablerte
|
||||||
|
palett (se DESIGN_SYSTEM.md) inntil videre — retemaes gradvis, skjerm for
|
||||||
|
skjerm, når de likevel røres i senere runder. Ingen egen stor
|
||||||
|
retemaings-runde er igangsatt eller bedt om.
|
||||||
|
|
|
||||||
66
app/rate_limit.py
Normal file
66
app/rate_limit.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""Enkel rate limiting i minnet.
|
||||||
|
|
||||||
|
Lagt til etter sikkerhetsgjennomgang 2026-08-09 (se ARCHITECTURE_DECISIONS.md
|
||||||
|
-tillegg): ingen av auth-endepunktene hadde noen form for forsøksbegrensning,
|
||||||
|
noe som gjorde passord- og 2FA-kode-gjetting praktisk mulig for en angriper
|
||||||
|
med nok samtidighet.
|
||||||
|
|
||||||
|
Trygt å holde i minnet KUN fordi `teecup_api` kjører som ÉN uvicorn-prosess
|
||||||
|
(ingen `--workers`-flagg, se Dockerfile) -- tilstanden deles derfor ikke
|
||||||
|
mellom flere prosesser/containere, samme kjente begrensning som andre
|
||||||
|
in-memory-cacher i appen (se "Cache over flere prosesser" i
|
||||||
|
ARCHITECTURE_DECISIONS.md sin liste over åpne punkter). Ved fremtidig
|
||||||
|
skalering til flere workers/containere MÅ dette flyttes til Redis eller
|
||||||
|
tilsvarende delt lager -- ikke glem å oppdatere denne kommentaren da.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from .errors import app_error
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip(request: Request) -> str:
|
||||||
|
"""Klient-IP via X-Forwarded-For -- trygt KUN fordi appen ikke er nåbar
|
||||||
|
unntatt gjennom Caddy, som terminerer TLS og proxyer videre over vanlig
|
||||||
|
http internt (samme tillitsmodell som should_use_secure_cookies() i
|
||||||
|
app/auth.py). Første adresse i kjeden er den faktiske klienten."""
|
||||||
|
forwarded = request.headers.get("x-forwarded-for")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimiter:
|
||||||
|
"""Fast-vindu-teller per nøkkel.
|
||||||
|
|
||||||
|
Bruk som FastAPI-dependency (`Depends(en_limiter)`) for IP-basert
|
||||||
|
begrensning, eller kall `.check(nøkkel)` direkte inne i en
|
||||||
|
endepunkt-funksjon for en mer presis nøkkel (f.eks. en pending-bruker-ID
|
||||||
|
-- riktig nøkkel når selve RESSURSEN som brute-forces er knyttet til en
|
||||||
|
bestemt konto, ikke avsender-IP-en)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
max_requests: int,
|
||||||
|
window_seconds: float,
|
||||||
|
message: str = "For mange forsøk. Vent litt og prøv igjen.",
|
||||||
|
):
|
||||||
|
self.max_requests = max_requests
|
||||||
|
self.window_seconds = window_seconds
|
||||||
|
self.message = message
|
||||||
|
self._buckets: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
def check(self, key: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
window_start = now - self.window_seconds
|
||||||
|
hits = [t for t in self._buckets.get(key, []) if t >= window_start]
|
||||||
|
if len(hits) >= self.max_requests:
|
||||||
|
self._buckets[key] = hits
|
||||||
|
raise app_error(429, "RATE_LIMITED", self.message)
|
||||||
|
hits.append(now)
|
||||||
|
self._buckets[key] = hits
|
||||||
|
|
||||||
|
def __call__(self, request: Request) -> None:
|
||||||
|
self.check(client_ip(request))
|
||||||
|
|
@ -73,6 +73,7 @@ from ..email import (
|
||||||
send_two_factor_code_email,
|
send_two_factor_code_email,
|
||||||
)
|
)
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
|
from ..rate_limit import RateLimiter
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
@ -81,6 +82,25 @@ _GENERIC_RESPONSE = {
|
||||||
"detail": "Hvis e-posten er gyldig, er en innloggingslenke sendt.",
|
"detail": "Hvis e-posten er gyldig, er en innloggingslenke sendt.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Rate limiting (sikkerhetsgjennomgang 2026-08-09, se app/rate_limit.py for
|
||||||
|
# begrunnelse/forbehold). IP-baserte grenser brukes der ressursen som
|
||||||
|
# beskyttes ikke er entydig knyttet til én konto (request-link/2fa-email-
|
||||||
|
# request kan gjelde en HVILKEN SOM HELST e-post, login-password likeledes).
|
||||||
|
# 2fa/verify og 2fa/setup/confirm brute-forcer derimot EN BESTEMT konto sin
|
||||||
|
# kode -- der er nøkkelen den faktiske (pending-/innloggede) bruker-ID-en i
|
||||||
|
# stedet for IP, se .check(...)-kallene i selve endepunktene, siden en
|
||||||
|
# angriper med en gyldig pending-sesjon ellers kunne omgått IP-basert
|
||||||
|
# begrensning ved å bytte IP mellom forsøk.
|
||||||
|
_login_password_limiter = RateLimiter(max_requests=10, window_seconds=600)
|
||||||
|
_request_link_limiter = RateLimiter(max_requests=5, window_seconds=600)
|
||||||
|
_two_factor_email_request_limiter = RateLimiter(max_requests=5, window_seconds=600)
|
||||||
|
_two_factor_verify_limiter = RateLimiter(
|
||||||
|
max_requests=8, window_seconds=300, message="For mange feilede forsøk. Vent litt og prøv igjen."
|
||||||
|
)
|
||||||
|
_two_factor_setup_confirm_limiter = RateLimiter(
|
||||||
|
max_requests=8, window_seconds=300, message="For mange feilede forsøk. Vent litt og prøv igjen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _hash_secret(raw: str) -> str:
|
def _hash_secret(raw: str) -> str:
|
||||||
"""Generisk SHA-256-hash -- brukt for BÅDE magic-link-tokens og
|
"""Generisk SHA-256-hash -- brukt for BÅDE magic-link-tokens og
|
||||||
|
|
@ -158,7 +178,7 @@ class MagicLinkRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/request-link")
|
@router.post("/request-link")
|
||||||
async def request_magic_link(body: MagicLinkRequest) -> dict:
|
async def request_magic_link(body: MagicLinkRequest, _rl: None = Depends(_request_link_limiter)) -> dict:
|
||||||
email = body.email.lower()
|
email = body.email.lower()
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
cooldown_cutoff = now - timedelta(seconds=settings.MAGIC_LINK_COOLDOWN_SECONDS)
|
cooldown_cutoff = now - timedelta(seconds=settings.MAGIC_LINK_COOLDOWN_SECONDS)
|
||||||
|
|
@ -369,7 +389,12 @@ class PasswordLoginRequest(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login-password", response_model=LoginResult)
|
@router.post("/login-password", response_model=LoginResult)
|
||||||
async def login_with_password(body: PasswordLoginRequest, response: Response, request: Request) -> LoginResult:
|
async def login_with_password(
|
||||||
|
body: PasswordLoginRequest,
|
||||||
|
response: Response,
|
||||||
|
request: Request,
|
||||||
|
_rl: None = Depends(_login_password_limiter),
|
||||||
|
) -> LoginResult:
|
||||||
email = body.email.lower()
|
email = body.email.lower()
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
user_row = await conn.fetchrow(
|
user_row = await conn.fetchrow(
|
||||||
|
|
@ -454,7 +479,10 @@ async def _send_two_factor_email_code(conn, user_id: str, email: str, locale: st
|
||||||
|
|
||||||
|
|
||||||
@router.post("/2fa/email/request")
|
@router.post("/2fa/email/request")
|
||||||
async def request_two_factor_email_code(pending: PendingUser = Depends(get_pending_user)) -> dict:
|
async def request_two_factor_email_code(
|
||||||
|
pending: PendingUser = Depends(get_pending_user),
|
||||||
|
_rl: None = Depends(_two_factor_email_request_limiter),
|
||||||
|
) -> dict:
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
user_row = await conn.fetchrow(
|
user_row = await conn.fetchrow(
|
||||||
"SELECT email::text AS email, preferred_locale, two_factor_method FROM app_user WHERE id = $1",
|
"SELECT email::text AS email, preferred_locale, two_factor_method FROM app_user WHERE id = $1",
|
||||||
|
|
@ -477,6 +505,10 @@ async def verify_two_factor(
|
||||||
request: Request,
|
request: Request,
|
||||||
pending: PendingUser = Depends(get_pending_user),
|
pending: PendingUser = Depends(get_pending_user),
|
||||||
) -> LoginResult:
|
) -> LoginResult:
|
||||||
|
# Nøkkel på pending.user_id, ikke IP -- se begrunnelse ved limiter-
|
||||||
|
# definisjonene lenger opp i filen.
|
||||||
|
_two_factor_verify_limiter.check(pending.user_id)
|
||||||
|
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
method = await conn.fetchval(
|
method = await conn.fetchval(
|
||||||
"SELECT two_factor_method FROM app_user WHERE id = $1", pending.user_id
|
"SELECT two_factor_method FROM app_user WHERE id = $1", pending.user_id
|
||||||
|
|
@ -588,6 +620,10 @@ async def confirm_two_factor_setup(
|
||||||
request: Request,
|
request: Request,
|
||||||
user: CurrentUser = Depends(get_current_or_enrolling_user),
|
user: CurrentUser = Depends(get_current_or_enrolling_user),
|
||||||
) -> SessionUser:
|
) -> SessionUser:
|
||||||
|
# Nøkkel på user.user_id, ikke IP -- se begrunnelse ved limiter-
|
||||||
|
# definisjonene lenger opp i filen.
|
||||||
|
_two_factor_setup_confirm_limiter.check(user.user_id)
|
||||||
|
|
||||||
if body.method == "totp":
|
if body.method == "totp":
|
||||||
if not body.secret or not verify_totp_code(body.secret, body.code.strip()):
|
if not body.secret or not verify_totp_code(body.secret, body.code.strip()):
|
||||||
raise app_error(401, "INVALID_2FA_CODE", "Feil kode.")
|
raise app_error(401, "INVALID_2FA_CODE", "Feil kode.")
|
||||||
|
|
@ -904,6 +940,22 @@ async def update_profile(
|
||||||
if not updates:
|
if not updates:
|
||||||
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
||||||
|
|
||||||
|
# Aldersgrense (2026-08-10, eksplisitt brukerkrav etter juridisk/
|
||||||
|
# personvern-vurdering, se ARCHITECTURE_DECISIONS.md): TeeCup skal
|
||||||
|
# ALDRI la noen under 13 år registrere seg (GDPR/personopplysningsloven
|
||||||
|
# -- under 13 krever verifiserbart foreldresamtykke, som appen ikke har
|
||||||
|
# støtte for). Håndhevet HER, server-side, ikke bare i skjemaet --
|
||||||
|
# klientside-validering alene er ingen reell grense.
|
||||||
|
if body.birth_date is not None:
|
||||||
|
today = date.today()
|
||||||
|
age = today.year - body.birth_date.year - (
|
||||||
|
(today.month, today.day) < (body.birth_date.month, body.birth_date.day)
|
||||||
|
)
|
||||||
|
if age < 13:
|
||||||
|
raise app_error(
|
||||||
|
400, "UNDER_MINIMUM_AGE", "Du må være minst 13 år for å bruke TeeCup."
|
||||||
|
)
|
||||||
|
|
||||||
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
||||||
values = list(updates.values())
|
values = list(updates.values())
|
||||||
values.append(user.user_id)
|
values.append(user.user_id)
|
||||||
|
|
|
||||||
|
|
@ -242,7 +242,7 @@ async def list_team_messages(
|
||||||
async def send_team_message(
|
async def send_team_message(
|
||||||
team_id: str,
|
team_id: str,
|
||||||
organization_id: str,
|
organization_id: str,
|
||||||
body: str | None = Form(default=None),
|
body: str | None = Form(default=None, max_length=2000),
|
||||||
image: UploadFile | None = File(default=None),
|
image: UploadFile | None = File(default=None),
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> MessageOut:
|
) -> MessageOut:
|
||||||
|
|
@ -572,9 +572,9 @@ async def get_feed(
|
||||||
@public_router.post("/{tournament_id}/feed", response_model=MessageOut, status_code=201)
|
@public_router.post("/{tournament_id}/feed", response_model=MessageOut, status_code=201)
|
||||||
async def post_to_feed(
|
async def post_to_feed(
|
||||||
tournament_id: str,
|
tournament_id: str,
|
||||||
body: str | None = Form(default=None),
|
body: str | None = Form(default=None, max_length=2000),
|
||||||
image: UploadFile | None = File(default=None),
|
image: UploadFile | None = File(default=None),
|
||||||
code: str | None = Form(default=None),
|
code: str | None = Form(default=None, max_length=20),
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> MessageOut:
|
) -> MessageOut:
|
||||||
if not body and image is None:
|
if not body and image is None:
|
||||||
|
|
|
||||||
|
|
@ -24,15 +24,16 @@ router = APIRouter()
|
||||||
|
|
||||||
class PlayerCreate(BaseModel):
|
class PlayerCreate(BaseModel):
|
||||||
display_name: str = Field(min_length=1, max_length=200)
|
display_name: str = Field(min_length=1, max_length=200)
|
||||||
handicap_index: float | None = None
|
# Samme grense som ProfileUpdate.handicap_index i auth.py (WHS-maksimum).
|
||||||
|
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
||||||
gender: str | None = Field(default=None, pattern="^[mfx]$")
|
gender: str | None = Field(default=None, pattern="^[mfx]$")
|
||||||
mobile: str | None = None
|
mobile: str | None = Field(default=None, max_length=30)
|
||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
birth_date: date | None = None
|
birth_date: date | None = None
|
||||||
nickname: str | None = None
|
nickname: str | None = Field(default=None, max_length=100)
|
||||||
country: str | None = None
|
country: str | None = Field(default=None, max_length=100)
|
||||||
club: str | None = None
|
club: str | None = Field(default=None, max_length=200)
|
||||||
club_member_number: str | None = None
|
club_member_number: str | None = Field(default=None, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
class Player(BaseModel):
|
class Player(BaseModel):
|
||||||
|
|
@ -108,15 +109,15 @@ class PlayerUpdate(BaseModel):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
display_name: str | None = Field(default=None, min_length=1, max_length=200)
|
display_name: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
handicap_index: float | None = None
|
handicap_index: float | None = Field(default=None, ge=-10, le=54)
|
||||||
gender: str | None = Field(default=None, pattern="^[mfx]$")
|
gender: str | None = Field(default=None, pattern="^[mfx]$")
|
||||||
mobile: str | None = None
|
mobile: str | None = Field(default=None, max_length=30)
|
||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
birth_date: date | None = None
|
birth_date: date | None = None
|
||||||
nickname: str | None = None
|
nickname: str | None = Field(default=None, max_length=100)
|
||||||
country: str | None = None
|
country: str | None = Field(default=None, max_length=100)
|
||||||
club: str | None = None
|
club: str | None = Field(default=None, max_length=200)
|
||||||
club_member_number: str | None = None
|
club_member_number: str | None = Field(default=None, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/orgs/{organization_id}/players/{player_id}", response_model=Player)
|
@router.patch("/orgs/{organization_id}/players/{player_id}", response_model=Player)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ live` -- klienten reagerer med å hente meldingslisten på nytt, samme
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from .. import storage
|
from .. import storage
|
||||||
from ..auth import CurrentUser, get_current_user, get_current_user_optional
|
from ..auth import CurrentUser, get_current_user, get_current_user_optional
|
||||||
|
|
@ -172,7 +172,7 @@ async def list_round_messages(
|
||||||
@router.post("/rounds/{round_id}/messages", response_model=RoundMessageOut, status_code=201)
|
@router.post("/rounds/{round_id}/messages", response_model=RoundMessageOut, status_code=201)
|
||||||
async def post_round_message(
|
async def post_round_message(
|
||||||
round_id: str,
|
round_id: str,
|
||||||
body: str | None = Form(default=None),
|
body: str | None = Form(default=None, max_length=2000),
|
||||||
image: UploadFile | None = File(default=None),
|
image: UploadFile | None = File(default=None),
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> RoundMessageOut:
|
) -> RoundMessageOut:
|
||||||
|
|
@ -314,7 +314,7 @@ _ROUND_MESSAGE_COMMENT_COLUMNS = """
|
||||||
|
|
||||||
|
|
||||||
class CommentIn(BaseModel):
|
class CommentIn(BaseModel):
|
||||||
body: str
|
body: str = Field(min_length=1, max_length=2000)
|
||||||
parent_comment_id: str | None = None
|
parent_comment_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,18 +114,20 @@ class PersonalCourseHoleIn(BaseModel):
|
||||||
|
|
||||||
class PersonalCourseTeeRatingIn(BaseModel):
|
class PersonalCourseTeeRatingIn(BaseModel):
|
||||||
gender: Literal["m", "f"]
|
gender: Literal["m", "f"]
|
||||||
course_rating: float
|
course_rating: float = Field(ge=50, le=90)
|
||||||
slope_rating: int = Field(ge=55, le=155)
|
slope_rating: int = Field(ge=55, le=155)
|
||||||
par: int
|
# Banens TOTALE par (18 hull), brukt i WHS-beregningen -- ikke per-hull-
|
||||||
|
# par (det er PersonalCourseHoleIn.par, ge=3/le=6, en helt annen ting).
|
||||||
|
par: int = Field(ge=54, le=90)
|
||||||
|
|
||||||
|
|
||||||
class PersonalCourseTeeIn(BaseModel):
|
class PersonalCourseTeeIn(BaseModel):
|
||||||
name: str
|
name: str = Field(min_length=1, max_length=100)
|
||||||
ratings: list[PersonalCourseTeeRatingIn] = Field(min_length=1, max_length=2)
|
ratings: list[PersonalCourseTeeRatingIn] = Field(min_length=1, max_length=2)
|
||||||
|
|
||||||
|
|
||||||
class PersonalCourseCreate(BaseModel):
|
class PersonalCourseCreate(BaseModel):
|
||||||
name: str
|
name: str = Field(min_length=1, max_length=100)
|
||||||
holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18)
|
holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18)
|
||||||
tees: list[PersonalCourseTeeIn] = Field(min_length=1)
|
tees: list[PersonalCourseTeeIn] = Field(min_length=1)
|
||||||
# Satt når banen opprettes med en annen offentlig custom-bane som
|
# Satt når banen opprettes med en annen offentlig custom-bane som
|
||||||
|
|
@ -3354,16 +3356,21 @@ async def update_bbb_hole(
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> BBBHoleOut:
|
) -> BBBHoleOut:
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
|
# Autorisasjon FØR noe annet leses/valideres -- reell, om enn liten,
|
||||||
|
# informasjonslekkasje funnet under sikkerhetsgjennomgang 2026-08-09:
|
||||||
|
# med format-sjekken først kunne en bruker uten tilgang skille
|
||||||
|
# "runden finnes ikke" fra "runden finnes, feil format" for en
|
||||||
|
# fremmed runde, uten å faktisk oppnå skrivetilgang. Se
|
||||||
|
# ARCHITECTURE_DECISIONS.md-tillegg for detalj.
|
||||||
|
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
||||||
|
|
||||||
round_row = await conn.fetchrow(
|
round_row = await conn.fetchrow(
|
||||||
"SELECT play_format, holes_planned FROM round WHERE id = $1", round_id
|
"SELECT play_format, holes_planned FROM round WHERE id = $1", round_id
|
||||||
)
|
)
|
||||||
if round_row is None:
|
|
||||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
||||||
if round_row["play_format"] != "bbb":
|
if round_row["play_format"] != "bbb":
|
||||||
raise app_error(400, "VALIDATION_FAILED", "Denne runden bruker ikke Bingo Bango Bongo.")
|
raise app_error(400, "VALIDATION_FAILED", "Denne runden bruker ikke Bingo Bango Bongo.")
|
||||||
if not (1 <= hole_number <= round_row["holes_planned"]):
|
if not (1 <= hole_number <= round_row["holes_planned"]):
|
||||||
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.")
|
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor rundens hullomfang.")
|
||||||
await _get_accessible_round_or_404(conn, round_id, user.user_id)
|
|
||||||
|
|
||||||
for pid in (body.bingo_participant_id, body.bango_participant_id, body.bongo_participant_id):
|
for pid in (body.bingo_participant_id, body.bango_participant_id, body.bongo_participant_id):
|
||||||
if pid is None:
|
if pid is None:
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,12 @@ export default async function LoggInnPage() {
|
||||||
// (en catch der ville slukt den stille og latt innloggingsskjemaet vises
|
// (en catch der ville slukt den stille og latt innloggingsskjemaet vises
|
||||||
// i stedet for å omdirigere, reell bug funnet under scratch-verifisering
|
// i stedet for å omdirigere, reell bug funnet under scratch-verifisering
|
||||||
// 2026-08-08, se CHANGELOG.md).
|
// 2026-08-08, se CHANGELOG.md).
|
||||||
|
// Ikke rett til /account -- se ARCHITECTURE_DECISIONS.md-tillegg
|
||||||
|
// 2026-08-09: /velkommen viser kontekst (PWA-installasjon, låste
|
||||||
|
// hurtighandlinger, presentasjon) FØR skjemaet, i stedet for at
|
||||||
|
// skjemaet dukker opp helt uten forklaring rett etter innlogging.
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
redirect(profileComplete ? "/dashboard" : "/account")
|
redirect(profileComplete ? "/dashboard" : "/velkommen")
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -39,13 +39,16 @@ export default async function Page() {
|
||||||
}
|
}
|
||||||
// Obligatorisk profil-fullføring (2026-07-22): en innlogget bruker som
|
// Obligatorisk profil-fullføring (2026-07-22): en innlogget bruker som
|
||||||
// ennå ikke har fylt ut de obligatoriske profilfeltene sendes til
|
// ennå ikke har fylt ut de obligatoriske profilfeltene sendes til
|
||||||
// /account (som selv viser en fokusert fullførings-visning) i stedet
|
// /velkommen (ikke rett til /account lenger, se ARCHITECTURE_DECISIONS.md
|
||||||
// for dashbordet -- se AccountSettings sin profile_complete-gren.
|
// -tillegg 2026-08-09 -- brukeren rapporterte at det direkte hoppet til
|
||||||
|
// skjemaet, uten kontekst, var feil). /velkommen viser PWA-installasjon,
|
||||||
|
// låste-men-forklarte hurtighandlinger og selve TeeCup-presentasjonen,
|
||||||
|
// med en tydelig lenke videre til /account.
|
||||||
//
|
//
|
||||||
// /logg-inn overtok rollen som ekte innloggingsside 2026-08-08 (se
|
// /logg-inn overtok rollen som ekte innloggingsside 2026-08-08 (se
|
||||||
// CHANGELOG.md) -- denne siden (root) er nå bare en tynn videresending,
|
// CHANGELOG.md) -- denne siden (root) er nå bare en tynn videresending,
|
||||||
// beholdt for gamle bokmerker/lenker til teecup.golf/. Selve
|
// beholdt for gamle bokmerker/lenker til teecup.golf/. Selve
|
||||||
// innloggingsskjemaet (LoginForm, Forest Green) er fjernet -- erstattet
|
// innloggingsskjemaet (LoginForm, Forest Green) er fjernet -- erstattet
|
||||||
// av TeeCupAuth (clubhouse-palett) på /logg-inn.
|
// av TeeCupAuth (clubhouse-palett) på /logg-inn.
|
||||||
redirect(authenticated ? (profileComplete ? "/dashboard" : "/account") : "/logg-inn")
|
redirect(authenticated ? (profileComplete ? "/dashboard" : "/velkommen") : "/logg-inn")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
59
frontend/app/velkommen/page.tsx
Normal file
59
frontend/app/velkommen/page.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import type { Metadata } from "next"
|
||||||
|
import { cookies } from "next/headers"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
import { Velkommen } from "@/components/teecup/velkommen"
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Velkommen – TeeCup",
|
||||||
|
description: "Fullfør profilen din for å komme i gang med TeeCup.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side, IKKE nettleser-fetch -- går derfor IKKE gjennom
|
||||||
|
// next.config.mjs sin rewrites() (samme mønster som /logg-inn/page.tsx og
|
||||||
|
// den tidligere app/page.tsx). Peker direkte på API-et.
|
||||||
|
const API_ORIGIN = process.env.TEECUP_API_ORIGIN || "http://localhost:8000"
|
||||||
|
// Må matche SESSION_COOKIE_NAME i app/auth.py -- ingen delt konstant på
|
||||||
|
// tvers av Python/TypeScript, samme mønster som andre API-kontrakt-felt
|
||||||
|
// som dupliseres bevisst i frontend-koden.
|
||||||
|
const SESSION_COOKIE_NAME = "teecup_session"
|
||||||
|
|
||||||
|
export default async function VelkommenPage() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const session = cookieStore.get(SESSION_COOKIE_NAME)
|
||||||
|
let authenticated = false
|
||||||
|
let profileComplete = true
|
||||||
|
let firstName: string | null = null
|
||||||
|
let displayName = ""
|
||||||
|
if (session) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_ORIGIN}/auth/me`, {
|
||||||
|
headers: { Cookie: `${SESSION_COOKIE_NAME}=${session.value}` },
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
authenticated = res.ok
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
profileComplete = Boolean(data.profile_complete)
|
||||||
|
firstName = data.first_name ?? null
|
||||||
|
displayName = data.display_name ?? ""
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// API utilgjengelig -- send til innlogging i stedet for å henge.
|
||||||
|
authenticated = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// redirect() kaster en egen NEXT_REDIRECT-kontrollflyt-exception som MÅ
|
||||||
|
// boble videre til Next.js -- må derfor stå UTENFOR try/catch-en over
|
||||||
|
// (reell bug funnet 2026-08-08 i /logg-inn/page.tsx av nøyaktig denne
|
||||||
|
// fallgruven, se CHANGELOG.md -- unngås her fra start).
|
||||||
|
if (!authenticated) {
|
||||||
|
redirect("/logg-inn")
|
||||||
|
}
|
||||||
|
// Denne siden finnes KUN for innlogget-men-ikke-fullført-profil -- en
|
||||||
|
// bruker med komplett profil skal aldri se den (send rett til dashbordet).
|
||||||
|
if (profileComplete) {
|
||||||
|
redirect("/dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Velkommen firstName={firstName} displayName={displayName} />
|
||||||
|
}
|
||||||
|
|
@ -57,6 +57,21 @@ const MAX_BAG_CLUBS = 14
|
||||||
// backend-endring trengs da, kun denne listen utvides.
|
// backend-endring trengs da, kun denne listen utvides.
|
||||||
const COUNTRIES = ["Norge"] as const
|
const COUNTRIES = ["Norge"] as const
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
function CountryField({ id, value, onChange }: { id: string; value: string; onChange: (v: string) => void }) {
|
function CountryField({ id, value, onChange }: { id: string; value: string; onChange: (v: string) => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|
@ -367,10 +382,17 @@ function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
const valid =
|
const valid =
|
||||||
firstName.trim() !== "" &&
|
firstName.trim() !== "" &&
|
||||||
lastName.trim() !== "" &&
|
lastName.trim() !== "" &&
|
||||||
birthDate !== "" &&
|
birthDate !== "" &&
|
||||||
|
!underMinimumAge &&
|
||||||
gender !== "" &&
|
gender !== "" &&
|
||||||
hcp.trim() !== "" &&
|
hcp.trim() !== "" &&
|
||||||
homeClub.trim() !== "" &&
|
homeClub.trim() !== "" &&
|
||||||
|
|
@ -484,8 +506,14 @@ function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void
|
||||||
type="date"
|
type="date"
|
||||||
value={birthDate}
|
value={birthDate}
|
||||||
onChange={(e) => setBirthDate(e.target.value)}
|
onChange={(e) => setBirthDate(e.target.value)}
|
||||||
|
aria-invalid={underMinimumAge}
|
||||||
className="h-11 rounded-xl"
|
className="h-11 rounded-xl"
|
||||||
/>
|
/>
|
||||||
|
{underMinimumAge ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive text-pretty">
|
||||||
|
Du må være minst 13 år for å bruke TeeCup.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="ob-gender" className="text-sm font-semibold">
|
<Label htmlFor="ob-gender" className="text-sm font-semibold">
|
||||||
|
|
@ -566,8 +594,12 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Aldersgrense (2026-08-10) -- se tilsvarende kommentar i ProfileOnboarding.
|
||||||
|
const underMinimumAge = birthDate !== "" && computeAge(birthDate) < 13
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
if (underMinimumAge) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
|
|
@ -722,8 +754,14 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
||||||
type="date"
|
type="date"
|
||||||
value={birthDate}
|
value={birthDate}
|
||||||
onChange={(e) => setBirthDate(e.target.value)}
|
onChange={(e) => setBirthDate(e.target.value)}
|
||||||
|
aria-invalid={underMinimumAge}
|
||||||
className="h-11 rounded-xl"
|
className="h-11 rounded-xl"
|
||||||
/>
|
/>
|
||||||
|
{underMinimumAge ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive text-pretty">
|
||||||
|
Du må være minst 13 år for å bruke TeeCup.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="gender" className="text-sm font-semibold">
|
<Label htmlFor="gender" className="text-sm font-semibold">
|
||||||
|
|
@ -836,7 +874,7 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
||||||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
{success && <p className="text-sm font-medium text-primary">Profilen er oppdatert.</p>}
|
{success && <p className="text-sm font-medium text-primary">Profilen er oppdatert.</p>}
|
||||||
|
|
||||||
<Button type="submit" disabled={saving} className="h-11 w-fit rounded-xl font-semibold">
|
<Button type="submit" disabled={saving || underMinimumAge} className="h-11 w-fit rounded-xl font-semibold">
|
||||||
{saving ? "Lagrer …" : "Lagre profil"}
|
{saving ? "Lagrer …" : "Lagre profil"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -311,8 +311,10 @@ export function Dashboard() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data: Me = await res.json()
|
const data: Me = await res.json()
|
||||||
|
// Ikke rett til /account -- se ARCHITECTURE_DECISIONS.md-tillegg
|
||||||
|
// 2026-08-09: /velkommen viser kontekst før skjemaet.
|
||||||
if (!data.profile_complete) {
|
if (!data.profile_complete) {
|
||||||
router.replace("/account")
|
router.replace("/velkommen")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setMe(data)
|
setMe(data)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,23 @@ function isIOS() {
|
||||||
return /iphone|ipad|ipod/i.test(window.navigator.userAgent)
|
return /iphone|ipad|ipod/i.test(window.navigator.userAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Siden iOS 16.4 kan en PWA installeres fra Chrome på iPhone også, ikke
|
||||||
|
// bare Safari (brukerønske 2026-08-10, tidligere tekst her hevdet feilaktig
|
||||||
|
// at man MÅTTE til Safari -- se PDF-vedlegg "Installere PWA fra Chrome på
|
||||||
|
// iPhone"). Del-ikonets PLASSERING er derimot ulik mellom nettleserne
|
||||||
|
// (Safari: nederst på skjermen. Chrome: oppe til høyre i adressefeltet),
|
||||||
|
// så instruksjonsteksten må skille dem for å faktisk stemme -- ikke bare
|
||||||
|
// fjerne Safari-nevnelsen og la den bli vag. Chrome-på-iOS har "CriOS" i
|
||||||
|
// user agent-strengen (deler ellers WebKit-motoren med Safari, så vanlig
|
||||||
|
// "Chrome"-sniffing ville feilaktig truffet Safari også).
|
||||||
|
function iosBrowser(): "safari" | "chrome" | "other" {
|
||||||
|
if (typeof window === "undefined") return "other"
|
||||||
|
const ua = window.navigator.userAgent
|
||||||
|
if (/crios/i.test(ua)) return "chrome"
|
||||||
|
if (/safari/i.test(ua) && !/crios|fxios|edgios/i.test(ua)) return "safari"
|
||||||
|
return "other"
|
||||||
|
}
|
||||||
|
|
||||||
function isDismissedForNow() {
|
function isDismissedForNow() {
|
||||||
if (typeof window === "undefined") return true
|
if (typeof window === "undefined") return true
|
||||||
const raw = window.localStorage.getItem(DISMISS_KEY)
|
const raw = window.localStorage.getItem(DISMISS_KEY)
|
||||||
|
|
@ -41,17 +58,20 @@ function dismissForNow() {
|
||||||
// reell installasjonsvei:
|
// reell installasjonsvei:
|
||||||
// - Android/Chrome-familien: ekte "Installer"-knapp via det fangede
|
// - Android/Chrome-familien: ekte "Installer"-knapp via det fangede
|
||||||
// beforeinstallprompt-eventet (lib/pwa-install.ts).
|
// beforeinstallprompt-eventet (lib/pwa-install.ts).
|
||||||
// - iOS Safari: Apple har ALDRI implementert beforeinstallprompt -- kun et
|
// - iOS (Safari ELLER Chrome, siden iOS 16.4 -- se iosBrowser() over):
|
||||||
|
// Apple har ALDRI implementert beforeinstallprompt der -- kun et
|
||||||
// instruksjonsbanner (Del-ikon -> "Legg til på Hjemskjerm") er mulig.
|
// instruksjonsbanner (Del-ikon -> "Legg til på Hjemskjerm") er mulig.
|
||||||
export function InstallPrompt() {
|
export function InstallPrompt() {
|
||||||
const [dismissed, setDismissed] = useState(true)
|
const [dismissed, setDismissed] = useState(true)
|
||||||
const [platform, setPlatform] = useState<"android" | "ios" | "none">("none")
|
const [platform, setPlatform] = useState<"android" | "ios" | "none">("none")
|
||||||
|
const [iosBrowserKind, setIosBrowserKind] = useState<"safari" | "chrome" | "other">("other")
|
||||||
const [deferredEvent, setDeferredEvent] = useState<BeforeInstallPromptEvent | null>(null)
|
const [deferredEvent, setDeferredEvent] = useState<BeforeInstallPromptEvent | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStandalone() || isDismissedForNow()) return
|
if (isStandalone() || isDismissedForNow()) return
|
||||||
if (isIOS()) {
|
if (isIOS()) {
|
||||||
setPlatform("ios")
|
setPlatform("ios")
|
||||||
|
setIosBrowserKind(iosBrowser())
|
||||||
setDismissed(false)
|
setDismissed(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -117,8 +137,13 @@ export function InstallPrompt() {
|
||||||
</h2>
|
</h2>
|
||||||
{platform === "ios" ? (
|
{platform === "ios" ? (
|
||||||
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#d8f0c4" }}>
|
<p className="text-sm leading-relaxed text-pretty" style={{ color: "#d8f0c4" }}>
|
||||||
Trykk <Share aria-hidden="true" className="inline size-4 -translate-y-0.5" /> (Del) nederst i
|
Trykk <Share aria-hidden="true" className="inline size-4 -translate-y-0.5" /> (Del){" "}
|
||||||
Safari, velg deretter <SquarePlus aria-hidden="true" className="inline size-4 -translate-y-0.5" />{" "}
|
{iosBrowserKind === "chrome"
|
||||||
|
? "oppe til høyre i adressefeltet"
|
||||||
|
: iosBrowserKind === "safari"
|
||||||
|
? "nederst på skjermen"
|
||||||
|
: "i nettleseren din"}
|
||||||
|
, velg deretter <SquarePlus aria-hidden="true" className="inline size-4 -translate-y-0.5" />{" "}
|
||||||
«Legg til på Hjem-skjerm».
|
«Legg til på Hjem-skjerm».
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -2049,6 +2049,10 @@ type ShotRecord = {
|
||||||
club: string
|
club: string
|
||||||
distance_meters: number
|
distance_meters: number
|
||||||
shared_round_message_id: string | null
|
shared_round_message_id: string | null
|
||||||
|
start_lat: number
|
||||||
|
start_lng: number
|
||||||
|
end_lat: number
|
||||||
|
end_lng: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function ShotMeasurementEntry({
|
function ShotMeasurementEntry({
|
||||||
|
|
@ -2292,6 +2296,17 @@ function ShotMeasurementEntry({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Satellittutsnitt for et allerede målt slag (brukerønske 2026-08-08: "jeg
|
||||||
|
// burde kunne se på et satellittfoto hvor jeg har slått hvert slag" -- listen
|
||||||
|
// viste tidligere kun tekst, ingen måte å se selve plasseringen). Samme
|
||||||
|
// mønster som forhåndsvisningen i shot-measurement-sheet.tsx sitt
|
||||||
|
// resultat-steg: klient-side, OFFENTLIG (URL-restriktert) Mapbox-token,
|
||||||
|
// ingen server-tur-retur -- koordinatene er allerede en del av ShotRecord.
|
||||||
|
function shotThumbnailUrl(shot: ShotRecord): string | null {
|
||||||
|
if (!process.env.NEXT_PUBLIC_MAPBOX_TOKEN) return null
|
||||||
|
return `https://api.mapbox.com/styles/v1/mapbox/satellite-streets-v12/static/pin-s-a+ff5a1f(${shot.start_lng},${shot.start_lat}),pin-s-b+2f7a3f(${shot.end_lng},${shot.end_lat})/auto/160x160@2x?padding=30&access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}`
|
||||||
|
}
|
||||||
|
|
||||||
function ShotList({
|
function ShotList({
|
||||||
shots,
|
shots,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
|
@ -2302,9 +2317,19 @@ function ShotList({
|
||||||
onShare?: (shot: ShotRecord) => void
|
onShare?: (shot: ShotRecord) => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ul className="flex w-full min-w-[14rem] flex-col gap-1 rounded-xl border border-border bg-card p-1.5">
|
<ul className="flex w-full min-w-[16rem] flex-col gap-1.5 rounded-xl border border-border bg-card p-1.5">
|
||||||
{shots.map((shot) => (
|
{shots.map((shot) => {
|
||||||
<li key={shot.id} className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm">
|
const thumbUrl = shotThumbnailUrl(shot)
|
||||||
|
return (
|
||||||
|
<li key={shot.id} className="flex items-center gap-3 rounded-lg p-1.5 text-sm">
|
||||||
|
{thumbUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- ekstern Mapbox-URL, ikke next/image-verdt
|
||||||
|
<img
|
||||||
|
src={thumbUrl || "/placeholder.svg"}
|
||||||
|
alt={`Satellittutsnitt av ${shot.club}-slaget, ${shot.distance_meters} meter, med start- og sluttpunkt markert`}
|
||||||
|
className="size-14 shrink-0 rounded-lg border border-border object-cover"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<span className="flex-1 truncate font-medium text-foreground">
|
<span className="flex-1 truncate font-medium text-foreground">
|
||||||
{shot.club} <span className="tabular-nums text-muted-foreground">— {shot.distance_meters} m</span>
|
{shot.club} <span className="tabular-nums text-muted-foreground">— {shot.distance_meters} m</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -2336,7 +2361,8 @@ function ShotList({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,47 @@
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import mapboxgl from "mapbox-gl"
|
import mapboxgl from "mapbox-gl"
|
||||||
import "mapbox-gl/dist/mapbox-gl.css"
|
import "mapbox-gl/dist/mapbox-gl.css"
|
||||||
import { MapPin } from "lucide-react"
|
import { Crosshair, MapPin } from "lucide-react"
|
||||||
|
import { haversineMeters } from "@/lib/geo"
|
||||||
|
|
||||||
type LngLat = { lng: number; lat: number }
|
type LngLat = { lng: number; lat: number }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MapPointPicker
|
* MapPointPicker
|
||||||
*
|
*
|
||||||
* Rendered ONLY when the user chooses "Velg punkt på kart", and always via
|
* To ulike modi, styrt av `referencePoint`:
|
||||||
* next/dynamic(..., { ssr: false }) from the parent. It mounts once per
|
* - Start-steget (referencePoint IKKE satt): rendret kun når brukeren
|
||||||
* sheet-open (once per step, since start and end use separate mounted
|
* velger "Velg punkt på kart". Trykk-for-å-plassere en markør, ett
|
||||||
* instances) and stays mounted until that step is left, so the Mapbox map is
|
* engangs-GPS-oppslag kun for sentrering.
|
||||||
* created exactly once (empty-deps useEffect) — taps, pans and marker drags
|
* - Ball-steget (referencePoint satt, ADR-048/049-tillegg): rendres
|
||||||
* never re-initialise it. This keeps Mapbox map loads (which are billed) to a
|
* ALLTID. Rent informativt kart (referansepunkt + løpende sporet
|
||||||
* single load per step-open.
|
* posisjon + sanntidsavstand) -- IKKE trykkbart for å plassere ballen.
|
||||||
|
* Ballens posisjon bekreftes alltid med sporet GPS-posisjon
|
||||||
|
* ("Jeg er ved ballen nå"), aldri ved kart-trykk (brukerønske
|
||||||
|
* 2026-08-09: "bekreft ballens posisjon er unødvendig" -- fjernet
|
||||||
|
* trykk-alternativet som fantes en kort periode før dette).
|
||||||
|
*
|
||||||
|
* Alltid via next/dynamic(..., { ssr: false }) fra foreldre-komponenten.
|
||||||
|
* Mountes én gang per steg og forblir mountet til det steget forlates, så
|
||||||
|
* selve Mapbox-KARTET initialiseres nøyaktig én gang (empty-deps
|
||||||
|
* useEffect) — pans/zoom re-initialiserer det aldri. Dette holder
|
||||||
|
* Mapbox-kartlastninger (som faktureres) til én per steg-åpning.
|
||||||
*/
|
*/
|
||||||
export function MapPointPicker({
|
export function MapPointPicker({
|
||||||
onConfirm,
|
onConfirm,
|
||||||
referencePoint,
|
referencePoint,
|
||||||
referenceLabel = "Utslag",
|
referenceLabel = "Utslag",
|
||||||
}: {
|
}: {
|
||||||
onConfirm: (lngLat: LngLat) => void
|
/** method er "map_tap" ved trykk-bekreftelse (kun start-steget), "gps"
|
||||||
|
* ved "Jeg er ved ballen nå"-bekreftelse (kun ball-steget). */
|
||||||
|
onConfirm: (lngLat: LngLat, method: "gps" | "map_tap") => void
|
||||||
/**
|
/**
|
||||||
* Satt KUN på ballposisjon-steget (ADR-048-tillegg 2026-08-08): et fast,
|
* Satt KUN på ballposisjon-steget: et fast, ikke-flyttbart
|
||||||
* ikke-flyttbart referansepunkt (utslagspunktet) vises på kartet, og
|
* referansepunkt (utslagspunktet) vises på kartet, og kartet forsøker å
|
||||||
* kartet forsøker å vise BÅDE dette og brukerens nåværende posisjon i
|
* vise BÅDE dette og brukerens nåværende posisjon i samme bilde --
|
||||||
* samme bilde -- brukerønske: "slagpunktet må være en del av det jeg ser".
|
* brukerønske: "slagpunktet må være en del av det jeg ser". Trigger
|
||||||
|
* samtidig løpende posisjonssporing (se lenger ned) -- ALDRI satt på
|
||||||
|
* start-steget, som fortsatt kun gjør ett engangs-oppslag.
|
||||||
*/
|
*/
|
||||||
referencePoint?: LngLat
|
referencePoint?: LngLat
|
||||||
referenceLabel?: string
|
referenceLabel?: string
|
||||||
|
|
@ -37,10 +52,17 @@ export function MapPointPicker({
|
||||||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||||
const markerRef = useRef<mapboxgl.Marker | null>(null)
|
const markerRef = useRef<mapboxgl.Marker | null>(null)
|
||||||
const referenceMarkerRef = useRef<mapboxgl.Marker | null>(null)
|
const referenceMarkerRef = useRef<mapboxgl.Marker | null>(null)
|
||||||
|
const liveMarkerRef = useRef<mapboxgl.Marker | null>(null)
|
||||||
|
const watchIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [point, setPoint] = useState<LngLat | null>(null)
|
const [point, setPoint] = useState<LngLat | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// Løpende posisjon + avstand-fra-referansepunkt-så-langt (ADR-048-tillegg
|
||||||
|
// 2026-08-08, brukerønske: "jeg ønsker å se lengden så langt i sanntid
|
||||||
|
// mens jeg nærmer meg ballen"). Kun i bruk når referencePoint er satt.
|
||||||
|
const [livePoint, setLivePoint] = useState<LngLat | null>(null)
|
||||||
|
const [liveDistance, setLiveDistance] = useState<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN
|
const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN
|
||||||
|
|
@ -48,7 +70,7 @@ export function MapPointPicker({
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError("Kartet er ikke tilgjengelig akkurat nå. Bruk «Min posisjon nå» i stedet.")
|
setError("Kartet er ikke tilgjengelig akkurat nå.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,9 +80,9 @@ export function MapPointPicker({
|
||||||
// Oslo-koordinat -- en kommentar her hevdet "real app centers on
|
// Oslo-koordinat -- en kommentar her hevdet "real app centers on
|
||||||
// last-known position", men den logikken fantes aldri, kun påstanden.
|
// last-known position", men den logikken fantes aldri, kun påstanden.
|
||||||
// Henter nå brukerens faktiske GPS-posisjon FØR kartet initialiseres
|
// Henter nå brukerens faktiske GPS-posisjon FØR kartet initialiseres
|
||||||
// (kun ETT kall, samme som "Min posisjon nå"-veien -- ingen løpende
|
// (ett engangs-oppslag for selve sentreringen), med Oslo som fallback
|
||||||
// watchPosition), med Oslo som fallback KUN hvis posisjon ikke kan
|
// KUN hvis posisjon ikke kan hentes (avslått tillatelse, tidsavbrudd,
|
||||||
// hentes (avslått tillatelse, tidsavbrudd, ingen støtte).
|
// ingen støtte).
|
||||||
// fitTo: satt når vi HAR en brukerposisjon OG et referansepunkt --
|
// fitTo: satt når vi HAR en brukerposisjon OG et referansepunkt --
|
||||||
// kartet zoomer/panorerer da til å vise begge (padding rundt), i stedet
|
// kartet zoomer/panorerer da til å vise begge (padding rundt), i stedet
|
||||||
// for bare å sentrere på ett av dem, slik at referansepunktet garantert
|
// for bare å sentrere på ett av dem, slik at referansepunktet garantert
|
||||||
|
|
@ -97,10 +119,17 @@ export function MapPointPicker({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tap to place/move a single draggable marker — never re-inits the map.
|
// Tap to place/move a single draggable marker — never re-inits the map.
|
||||||
|
// KUN på start-steget (ingen referencePoint): brukerønske 2026-08-09,
|
||||||
|
// "bekreft ballens posisjon er unødvendig" -- ballens posisjon
|
||||||
|
// bekreftes nå ALLTID med sporet GPS-posisjon ("Jeg er ved ballen
|
||||||
|
// nå"), aldri ved å trykke på kartet. Kartet på ball-steget er dermed
|
||||||
|
// rent informativt (referansepunkt + sanntidsposisjon/-avstand), ikke
|
||||||
|
// interaktivt for plassering.
|
||||||
|
if (!referencePoint) {
|
||||||
map.on("click", (e) => {
|
map.on("click", (e) => {
|
||||||
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat }
|
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat }
|
||||||
if (!markerRef.current) {
|
if (!markerRef.current) {
|
||||||
const marker = new mapboxgl.Marker({ draggable: true, color: referencePoint ? "#2f7a3f" : "#d2551a" })
|
const marker = new mapboxgl.Marker({ draggable: true, color: "#d2551a" })
|
||||||
.setLngLat(e.lngLat)
|
.setLngLat(e.lngLat)
|
||||||
.addTo(map)
|
.addTo(map)
|
||||||
marker.on("dragend", () => {
|
marker.on("dragend", () => {
|
||||||
|
|
@ -114,6 +143,7 @@ export function MapPointPicker({
|
||||||
setPoint(lngLat)
|
setPoint(lngLat)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const OSLO_FALLBACK: [number, number] = [10.7522, 59.9139]
|
const OSLO_FALLBACK: [number, number] = [10.7522, 59.9139]
|
||||||
const fallbackCenter: [number, number] = referencePoint
|
const fallbackCenter: [number, number] = referencePoint
|
||||||
|
|
@ -136,8 +166,46 @@ export function MapPointPicker({
|
||||||
initMap(fallbackCenter)
|
initMap(fallbackCenter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Løpende posisjonssporing -- KUN på ballposisjon-steget (referencePoint
|
||||||
|
// satt). Egen, bevisst unntak fra ADR-048s opprinnelige "ingen løpende
|
||||||
|
// watchPosition"-prinsipp (som gjaldt kostnadskontroll for KART-
|
||||||
|
// lastninger): watchPosition i seg selv koster ingenting hos Mapbox --
|
||||||
|
// det er ren nettleser-GPS, ikke et kartlastnings-kall -- og brukeren
|
||||||
|
// ba eksplisitt om sanntids-avstand mens de går mot ballen. Kartet
|
||||||
|
// forblir uansett kun ÉN instans (samme map-objekt gjenbrukes, aldri
|
||||||
|
// re-initialisert), kun MARKØRPOSISJONEN oppdateres løpende.
|
||||||
|
if (referencePoint && typeof navigator !== "undefined" && navigator.geolocation) {
|
||||||
|
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||||
|
(pos) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const p = { lng: pos.coords.longitude, lat: pos.coords.latitude }
|
||||||
|
setLivePoint(p)
|
||||||
|
setLiveDistance(haversineMeters(referencePoint, p))
|
||||||
|
if (mapRef.current) {
|
||||||
|
if (!liveMarkerRef.current) {
|
||||||
|
const el = document.createElement("div")
|
||||||
|
el.className = "size-4 rounded-full border-2 border-white bg-[#2b7fff] shadow-md"
|
||||||
|
liveMarkerRef.current = new mapboxgl.Marker({ element: el }).setLngLat([p.lng, p.lat]).addTo(mapRef.current)
|
||||||
|
} else {
|
||||||
|
liveMarkerRef.current.setLngLat([p.lng, p.lat])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Feil/avslått tillatelse underveis -- degraderer grasiøst, bruker
|
||||||
|
// kan fortsatt trykke selv ut ballens posisjon på kartet.
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, maximumAge: 1000 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
if (watchIdRef.current !== null && typeof navigator !== "undefined" && navigator.geolocation) {
|
||||||
|
navigator.geolocation.clearWatch(watchIdRef.current)
|
||||||
|
}
|
||||||
|
liveMarkerRef.current?.remove()
|
||||||
|
liveMarkerRef.current = null
|
||||||
referenceMarkerRef.current?.remove()
|
referenceMarkerRef.current?.remove()
|
||||||
referenceMarkerRef.current = null
|
referenceMarkerRef.current = null
|
||||||
markerRef.current?.remove()
|
markerRef.current?.remove()
|
||||||
|
|
@ -190,25 +258,56 @@ export function MapPointPicker({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loaded && !point && !error ? (
|
{/* Trykk-instruksjon KUN på start-steget -- ball-steget har ingen
|
||||||
|
trykk-handling lenger (se click-handler-kommentaren over). */}
|
||||||
|
{loaded && !point && !error && !referencePoint ? (
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center px-4">
|
<div className="pointer-events-none absolute inset-x-0 top-4 flex justify-center px-4">
|
||||||
<p className="flex items-center gap-2 rounded-full bg-card/95 px-4 py-2 text-sm font-semibold text-foreground shadow-md">
|
<p className="flex items-center gap-2 rounded-full bg-card/95 px-4 py-2 text-sm font-semibold text-foreground shadow-md">
|
||||||
<MapPin className="size-4 shrink-0" aria-hidden="true" />
|
<MapPin className="size-4 shrink-0" aria-hidden="true" />
|
||||||
{referencePoint ? "Trykk der ballen ligger" : "Trykk på kartet for å plassere punktet"}
|
Trykk på kartet for å plassere punktet
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Sanntids-avstand så langt -- kun ball-steget. Brukerønske
|
||||||
|
2026-08-08: "jeg ønsker å se lengden så langt i sanntid mens
|
||||||
|
jeg nærmer meg ballen". */}
|
||||||
|
{referencePoint && liveDistance !== null ? (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
|
||||||
|
<div className="flex flex-col items-center gap-0.5 rounded-2xl bg-card/95 px-5 py-2.5 shadow-md">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Avstand så langt
|
||||||
|
</span>
|
||||||
|
<span className="text-3xl font-extrabold tabular-nums leading-none text-foreground">
|
||||||
|
{Math.round(liveDistance)}
|
||||||
|
<span className="ml-1 text-lg font-bold text-muted-foreground">m</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
<div className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||||
|
{referencePoint ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!livePoint}
|
||||||
|
onClick={() => livePoint && onConfirm(livePoint, "gps")}
|
||||||
|
className="inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Crosshair className="size-5 shrink-0" aria-hidden="true" />
|
||||||
|
Jeg er ved ballen nå
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!point}
|
disabled={!point}
|
||||||
onClick={() => point && onConfirm(point)}
|
onClick={() => point && onConfirm(point, "map_tap")}
|
||||||
className="inline-flex min-h-14 w-full items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
className="inline-flex min-h-14 w-full items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{referencePoint ? "Bekreft ballens posisjon" : "Bekreft punkt"}
|
Bekreft punkt
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Crosshair, LocateFixed, MapPin, X, ChevronLeft, Check } from "lucide-re
|
||||||
import { ClubPicker } from "@/components/teecup/club-picker"
|
import { ClubPicker } from "@/components/teecup/club-picker"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { haversineMeters } from "@/lib/geo"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazy, client-only reference to the map step. Because dynamic() defers the
|
* Lazy, client-only reference to the map step. Because dynamic() defers the
|
||||||
|
|
@ -26,7 +27,7 @@ const MapPointPicker = dynamic(
|
||||||
)
|
)
|
||||||
|
|
||||||
type LngLat = { lng: number; lat: number }
|
type LngLat = { lng: number; lat: number }
|
||||||
type Step = "start" | "map" | "end" | "end_map" | "club" | "result"
|
type Step = "start" | "map" | "end" | "club" | "result"
|
||||||
type GeoStatus = "idle" | "loading" | "error"
|
type GeoStatus = "idle" | "loading" | "error"
|
||||||
|
|
||||||
export type ShotMeasurementSheetProps = {
|
export type ShotMeasurementSheetProps = {
|
||||||
|
|
@ -63,15 +64,7 @@ export type ShotMeasurementSheetProps = {
|
||||||
// The real app passes distanceMeters computed server-/caller-side.
|
// The real app passes distanceMeters computed server-/caller-side.
|
||||||
function previewDistance(a: LngLat | null, b: LngLat | null): number {
|
function previewDistance(a: LngLat | null, b: LngLat | null): number {
|
||||||
if (!a || !b) return 0
|
if (!a || !b) return 0
|
||||||
const R = 6371000
|
return Math.round(haversineMeters(a, b))
|
||||||
const toRad = (d: number) => (d * Math.PI) / 180
|
|
||||||
const dLat = toRad(b.lat - a.lat)
|
|
||||||
const dLng = toRad(b.lng - a.lng)
|
|
||||||
const lat1 = toRad(a.lat)
|
|
||||||
const lat2 = toRad(b.lat)
|
|
||||||
const h =
|
|
||||||
Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2
|
|
||||||
return Math.round(2 * R * Math.asin(Math.sqrt(h)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShotMeasurementSheet({
|
export function ShotMeasurementSheet({
|
||||||
|
|
@ -91,7 +84,6 @@ export function ShotMeasurementSheet({
|
||||||
const [endPoint, setEndPoint] = useState<LngLat | null>(null)
|
const [endPoint, setEndPoint] = useState<LngLat | null>(null)
|
||||||
|
|
||||||
const [startStatus, setStartStatus] = useState<GeoStatus>("idle")
|
const [startStatus, setStartStatus] = useState<GeoStatus>("idle")
|
||||||
const [endStatus, setEndStatus] = useState<GeoStatus>("idle")
|
|
||||||
|
|
||||||
const [club, setClub] = useState<string>("")
|
const [club, setClub] = useState<string>("")
|
||||||
const [share, setShare] = useState(false)
|
const [share, setShare] = useState(false)
|
||||||
|
|
@ -124,25 +116,6 @@ export function ShotMeasurementSheet({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ball position via GPS. Deliberately NOT auto-fired on step mount (real
|
|
||||||
// bug found 2026-08-08: firing immediately gave the user zero time to
|
|
||||||
// actually walk from the start point to the ball, so start/end ended up
|
|
||||||
// at ~the same spot and ~the same instant -- distance was always 0m).
|
|
||||||
// Requires an explicit "Jeg er ved ballen nå" tap once the user has
|
|
||||||
// actually walked there.
|
|
||||||
function measureEndPoint() {
|
|
||||||
setEndMethod("gps")
|
|
||||||
setEndStatus("loading")
|
|
||||||
acquirePosition(
|
|
||||||
(p) => {
|
|
||||||
setEndPoint(p)
|
|
||||||
setEndStatus("idle")
|
|
||||||
setStep("club")
|
|
||||||
},
|
|
||||||
() => setEndStatus("error"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const distance =
|
const distance =
|
||||||
typeof distanceMeters === "number" ? distanceMeters : previewDistance(startPoint, endPoint)
|
typeof distanceMeters === "number" ? distanceMeters : previewDistance(startPoint, endPoint)
|
||||||
|
|
||||||
|
|
@ -167,11 +140,7 @@ export function ShotMeasurementSheet({
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (step === "map" || step === "end") {
|
if (step === "map" || step === "end") {
|
||||||
setStartStatus("idle")
|
setStartStatus("idle")
|
||||||
setEndStatus("idle")
|
|
||||||
setStep("start")
|
setStep("start")
|
||||||
} else if (step === "end_map") {
|
|
||||||
setEndStatus("idle")
|
|
||||||
setStep("end")
|
|
||||||
} else if (step === "club") {
|
} else if (step === "club") {
|
||||||
// Re-measure the ball position rather than reusing a stale fix.
|
// Re-measure the ball position rather than reusing a stale fix.
|
||||||
setEndPoint(null)
|
setEndPoint(null)
|
||||||
|
|
@ -198,7 +167,7 @@ export function ShotMeasurementSheet({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMapStep = step === "map" || step === "end_map"
|
const onMapStep = step === "map" || step === "end"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -308,100 +277,27 @@ export function ShotMeasurementSheet({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* method-argumentet i onConfirm ignoreres bevisst her -- alltid
|
||||||
|
"map_tap" på start-steget siden ingen referencePoint er satt. */}
|
||||||
{step === "map" ? <MapPointPicker onConfirm={(p) => {
|
{step === "map" ? <MapPointPicker onConfirm={(p) => {
|
||||||
setStartPoint(p)
|
setStartPoint(p)
|
||||||
setStep("end")
|
setStep("end")
|
||||||
}} /> : null}
|
}} /> : null}
|
||||||
|
|
||||||
|
{/* Ball-steget viser NÅ alltid kartet (ADR-048-tillegg 2026-08-08,
|
||||||
|
brukerønske: "jeg skal se start og slutt på et satelittfoto...
|
||||||
|
mens jeg går opp mot ballen", "jeg ønsker å se lengden så langt
|
||||||
|
i sanntid"). Ingen egen valg-skjerm lenger -- MapPointPicker
|
||||||
|
selv viser BÅDE "Bekreft ballens posisjon" (trykket punkt) og
|
||||||
|
"Jeg er ved ballen nå" (nåværende sporede posisjon) i footeren
|
||||||
|
når referencePoint er satt, se den komponenten. */}
|
||||||
{step === "end" ? (
|
{step === "end" ? (
|
||||||
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-6 text-center">
|
|
||||||
{endStatus === "idle" ? (
|
|
||||||
<>
|
|
||||||
<p className="text-base text-muted-foreground text-pretty">
|
|
||||||
Gå til der ballen ligger. Mål med GPS når du er fremme, eller
|
|
||||||
se kartet mens du går og pek selv ut hvor ballen ligger.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={measureEndPoint}
|
|
||||||
className="flex min-h-16 w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
||||||
<Crosshair className="size-6" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-col">
|
|
||||||
<span className="text-base font-bold">Jeg er ved ballen nå</span>
|
|
||||||
<span className="text-sm text-muted-foreground">Mål med GPS</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setEndMethod("map_tap")
|
|
||||||
setStep("end_map")
|
|
||||||
}}
|
|
||||||
className="flex min-h-16 w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-full bg-brand-orange/15 text-brand-orange">
|
|
||||||
<MapPin className="size-6" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
<span className="flex flex-col">
|
|
||||||
<span className="text-base font-bold">Vis kart mens jeg går</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Se utslagspunktet på kartet, og pek ut ballen
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{endStatus === "loading" ? (
|
|
||||||
<>
|
|
||||||
<div className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
|
||||||
<p className="text-base text-muted-foreground">Finner posisjonen din …</p>
|
|
||||||
<p className="text-sm text-muted-foreground text-pretty">
|
|
||||||
Stå ved ballen mens vi måler sluttpunktet.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{endStatus === "error" ? (
|
|
||||||
<div className="flex w-full flex-col gap-3 rounded-2xl border border-border bg-card p-4 text-left">
|
|
||||||
<p role="alert" className="text-base text-destructive text-pretty">
|
|
||||||
Kunne ikke måle ballposisjonen. Prøv igjen når du står ved ballen.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={measureEndPoint}
|
|
||||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
<Crosshair className="size-5" aria-hidden="true" />
|
|
||||||
Prøv igjen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setEndStatus("idle")
|
|
||||||
setEndMethod("map_tap")
|
|
||||||
setStep("end_map")
|
|
||||||
}}
|
|
||||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl px-4 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
||||||
>
|
|
||||||
Velg punkt på kart i stedet
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{step === "end_map" ? (
|
|
||||||
<MapPointPicker
|
<MapPointPicker
|
||||||
referencePoint={startPoint ?? undefined}
|
referencePoint={startPoint ?? undefined}
|
||||||
referenceLabel="Utslag"
|
referenceLabel="Utslag"
|
||||||
onConfirm={(p) => {
|
onConfirm={(p, method) => {
|
||||||
setEndPoint(p)
|
setEndPoint(p)
|
||||||
|
setEndMethod(method)
|
||||||
setStep("club")
|
setStep("club")
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,22 @@ function isActive(pathname: string, href: string) {
|
||||||
return pathname === href || pathname.startsWith(`${href}/`)
|
return pathname === href || pathname.startsWith(`${href}/`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BottomNav({ tabs = TABS }: { tabs?: Tab[] }) {
|
export function BottomNav({
|
||||||
|
tabs = TABS,
|
||||||
|
disabledHrefs,
|
||||||
|
onDisabledClick,
|
||||||
|
}: {
|
||||||
|
tabs?: Tab[]
|
||||||
|
/**
|
||||||
|
* Fanenes hrefs som skal vises låst (ADR-049, /velkommen — innlogget-
|
||||||
|
* men-ikke-fullført-profil skal se HELE den vanlige navigasjonen, ikke
|
||||||
|
* en avkortet versjon, men kun kunne gå til profilen før resten låses
|
||||||
|
* opp). Rendres som en `<button>` i stedet for `<Link>`, aldri
|
||||||
|
* `aria-current`, og navigerer aldri.
|
||||||
|
*/
|
||||||
|
disabledHrefs?: string[]
|
||||||
|
onDisabledClick?: (tab: Tab) => void
|
||||||
|
}) {
|
||||||
const pathname = usePathname() ?? ""
|
const pathname = usePathname() ?? ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -66,34 +81,27 @@ export function BottomNav({ tabs = TABS }: { tabs?: Tab[] }) {
|
||||||
<ul className="mx-auto flex max-w-xl items-stretch justify-around gap-1 px-2 pb-[env(safe-area-inset-bottom)]">
|
<ul className="mx-auto flex max-w-xl items-stretch justify-around gap-1 px-2 pb-[env(safe-area-inset-bottom)]">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const Icon = tab.icon
|
const Icon = tab.icon
|
||||||
|
const disabled = disabledHrefs?.includes(tab.href) ?? false
|
||||||
const active =
|
const active =
|
||||||
isActive(pathname, tab.href) || (tab.matchPaths?.some((p) => isActive(pathname, p)) ?? false)
|
!disabled &&
|
||||||
return (
|
(isActive(pathname, tab.href) || (tab.matchPaths?.some((p) => isActive(pathname, p)) ?? false))
|
||||||
<li key={tab.href} className="flex-1">
|
const itemClassName = cn(
|
||||||
<Link
|
|
||||||
href={tab.href}
|
|
||||||
aria-current={active ? "page" : undefined}
|
|
||||||
className={cn(
|
|
||||||
"group relative flex min-h-[56px] flex-col items-center justify-center gap-1 rounded-xl px-1 py-2 text-[11px] font-semibold leading-none transition-colors",
|
"group relative flex min-h-[56px] flex-col items-center justify-center gap-1 rounded-xl px-1 py-2 text-[11px] font-semibold leading-none transition-colors",
|
||||||
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tee-strong",
|
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tee-strong",
|
||||||
active
|
disabled
|
||||||
|
? "text-neutral-400"
|
||||||
|
: active
|
||||||
? "text-tee-strong"
|
? "text-tee-strong"
|
||||||
: "text-neutral-500 hover:text-neutral-900",
|
: "text-neutral-500 hover:text-neutral-900",
|
||||||
)}
|
)
|
||||||
>
|
const iconWrapClassName = cn(
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"relative flex h-8 w-12 items-center justify-center rounded-full transition-colors",
|
"relative flex h-8 w-12 items-center justify-center rounded-full transition-colors",
|
||||||
active
|
active ? "bg-tee/20" : "bg-transparent group-hover:bg-black/[0.04]",
|
||||||
? "bg-tee/20"
|
)
|
||||||
: "bg-transparent group-hover:bg-black/[0.04]",
|
const inner = (
|
||||||
)}
|
<>
|
||||||
>
|
<span className={iconWrapClassName}>
|
||||||
<Icon
|
<Icon className="h-[22px] w-[22px]" strokeWidth={active ? 2.6 : 2} aria-hidden="true" />
|
||||||
className="h-[22px] w-[22px]"
|
|
||||||
strokeWidth={active ? 2.6 : 2}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
{typeof tab.badge === "number" && tab.badge > 0 && (
|
{typeof tab.badge === "number" && tab.badge > 0 && (
|
||||||
<span
|
<span
|
||||||
className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-cup px-1 text-[10px] font-bold text-white ring-2 ring-white"
|
className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-cup px-1 text-[10px] font-bold text-white ring-2 ring-white"
|
||||||
|
|
@ -109,7 +117,24 @@ export function BottomNav({ tabs = TABS }: { tabs?: Tab[] }) {
|
||||||
<span className="sr-only">{`, ${tab.badge} nye`}</span>
|
<span className="sr-only">{`, ${tab.badge} nye`}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<li key={tab.href} className="flex-1">
|
||||||
|
{disabled ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-disabled="true"
|
||||||
|
onClick={() => onDisabledClick?.(tab)}
|
||||||
|
className={itemClassName}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link href={tab.href} aria-current={active ? "page" : undefined} className={itemClassName}>
|
||||||
|
{inner}
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
170
frontend/components/teecup/velkommen.tsx
Normal file
170
frontend/components/teecup/velkommen.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Ny landingsside for innlogget-men-ikke-fullført-profil (ADR-048-tillegg
|
||||||
|
// 2026-08-09 -- se ARCHITECTURE_DECISIONS.md). Erstatter det tidligere
|
||||||
|
// direkte hoppet til /account: brukeren møtte skjemaet FØR de fikk noen
|
||||||
|
// kontekst om hva TeeCup er, uten mulighet til å orientere seg -- brukeren
|
||||||
|
// rapporterte eksplisitt at dette var feil. Denne siden viser i stedet en
|
||||||
|
// vanlig PWA-installasjonsoppfordring, LÅSTE (men synlige, forklarte)
|
||||||
|
// hurtighandlinger, og selve TeeCup-presentasjonen (innhold hentet fra
|
||||||
|
// teecup-beskrivelse.md, skrevet om til kort UI-tekst) -- før brukeren
|
||||||
|
// dyttes inn i skjemaet.
|
||||||
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { ArrowRight, Calculator, Flag, KeyRound, Lock, Monitor, Trophy, Users } from "lucide-react"
|
||||||
|
import { InstallPrompt } from "@/components/install-prompt"
|
||||||
|
import { TeeCupWordmark } from "@/components/teecup/wordmark"
|
||||||
|
import { BottomNav } from "@/components/teecup/bottom-nav"
|
||||||
|
|
||||||
|
// Alle fanene unntatt "Profil" vises låst (brukerønske 2026-08-09: "Alle
|
||||||
|
// knappene bør egentlig være der, deaktivert, bortsett fra til profilen")
|
||||||
|
// -- selve navigasjonens FORM skal se ut som den vanlige appen, ikke en
|
||||||
|
// avkortet versjon, men kun profilen skal faktisk være nåbar herfra.
|
||||||
|
const LOCKED_NAV_HREFS = ["/dashboard", "/my-rounds", "/dashboard#kommende-turneringer", "/more"]
|
||||||
|
|
||||||
|
export function Velkommen({ firstName, displayName }: { firstName: string | null; displayName: string }) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [showLockedHint, setShowLockedHint] = useState(false)
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await fetch("/auth/logout", { method: "POST", credentials: "include" })
|
||||||
|
} finally {
|
||||||
|
router.replace("/logg-inn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col bg-clubhouse-bg text-clubhouse-ink">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-clubhouse-border bg-clubhouse-card/95 backdrop-blur pt-[env(safe-area-inset-top)] supports-[backdrop-filter]:bg-clubhouse-card/80">
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl items-center justify-between gap-4 px-5 py-4">
|
||||||
|
<TeeCupWordmark className="h-7" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="inline-flex min-h-[44px] items-center rounded-lg px-3 text-sm font-semibold text-clubhouse-muted transition-colors hover:bg-clubhouse-field hover:text-clubhouse-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
|
||||||
|
>
|
||||||
|
Logg ut
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-5 pb-24 pt-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-extrabold text-balance">{`Velkommen, ${firstName ?? displayName}!`}</h1>
|
||||||
|
<p className="mt-1 text-[0.9375rem] text-clubhouse-muted text-pretty">
|
||||||
|
Du er logget inn. Ett steg gjenstår før du kan bli med i runder og turneringer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<InstallPrompt />
|
||||||
|
|
||||||
|
{/* Fullfør profilen -- primær CTA */}
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-tee-strong/30 bg-tee/10 p-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold">Fullfør profilen din</h2>
|
||||||
|
<p className="mt-1 text-sm text-clubhouse-muted text-pretty">
|
||||||
|
Navn, fødselsdato, kjønn og HCP trengs for at runder og handicap skal beregnes riktig.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/account"
|
||||||
|
className="inline-flex min-h-[48px] w-fit items-center gap-2 rounded-xl bg-tee-strong px-5 text-base font-bold text-white transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
|
||||||
|
>
|
||||||
|
Fyll ut nå
|
||||||
|
<ArrowRight aria-hidden="true" className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Låste hurtighandlinger -- synlige, forklarte, ikke skjulte. Trykk
|
||||||
|
viser en delt påminnelse (fast plassert over bunnnavigasjonen,
|
||||||
|
se lenger ned -- fungerer uansett om det er en hurtighandling
|
||||||
|
eller en bunnfane som trykkes) i stedet for å navigere. */}
|
||||||
|
<section aria-label="Hurtighandlinger (låst til profilen er fullført)" className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-3 gap-2 sm:gap-3">
|
||||||
|
<LockedAction icon={Flag} label="Ny runde" onClick={() => setShowLockedHint(true)} />
|
||||||
|
<LockedAction icon={Trophy} label="Ny turnering" onClick={() => setShowLockedHint(true)} />
|
||||||
|
<LockedAction icon={KeyRound} label="Bli med med kode" onClick={() => setShowLockedHint(true)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Om TeeCup -- innhold fra teecup-beskrivelse.md, skrevet om til kort
|
||||||
|
UI-tekst (ikke limt inn rått). */}
|
||||||
|
<section className="flex flex-col gap-4 rounded-2xl border border-clubhouse-border bg-clubhouse-card p-5 sm:p-6">
|
||||||
|
<h2 className="text-lg font-extrabold">Hva er TeeCup?</h2>
|
||||||
|
<p className="text-[0.9375rem] leading-relaxed text-clubhouse-muted text-pretty">
|
||||||
|
En mobil-først app for golfturneringer og live-scoring — bygget for bedrifter og
|
||||||
|
vennegjenger som spiller sammen. Klubbhus-stemning, ikke countryklubb-snobberi.
|
||||||
|
</p>
|
||||||
|
<ul className="flex flex-col gap-2.5 text-[0.9375rem] text-clubhouse-ink">
|
||||||
|
<li className="flex items-start gap-2.5">
|
||||||
|
<Trophy aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-cup-strong" />
|
||||||
|
Ni turneringsformater — fra vanlig slagspill til Scramble, Chapman og Money Ball.
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2.5">
|
||||||
|
<Flag aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-tee-strong" />
|
||||||
|
Ekte, live scoreføring ute på banen — leaderboardet oppdaterer seg i sanntid.
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2.5">
|
||||||
|
<Calculator aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-tee-strong" />
|
||||||
|
Fullt WHS-handicapsystem, ikke en forenklet tilnærming.
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2.5">
|
||||||
|
<Users aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-cup-strong" />
|
||||||
|
Venner, delte runder og følg-live for tilskuere.
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2.5">
|
||||||
|
<Monitor aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-tee-strong" />
|
||||||
|
Turneringsadministrasjon og -presentasjon fungerer minst like godt på PC som på telefon.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{showLockedHint ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="fixed inset-x-0 z-40 mx-auto max-w-2xl px-4"
|
||||||
|
style={{ bottom: "calc(56px + env(safe-area-inset-bottom) + 12px)" }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-clubhouse-border bg-clubhouse-card p-3 text-sm shadow-lg shadow-black/10">
|
||||||
|
<span className="text-clubhouse-ink">Du må fylle ut profilen din før du kan bruke dette.</span>
|
||||||
|
<Link
|
||||||
|
href="/account"
|
||||||
|
onClick={() => setShowLockedHint(false)}
|
||||||
|
className="shrink-0 font-semibold text-tee-strong underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Gå til skjemaet
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<BottomNav disabledHrefs={LOCKED_NAV_HREFS} onDisabledClick={() => setShowLockedHint(true)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LockedAction({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
icon: typeof Flag
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-disabled="true"
|
||||||
|
className="relative flex min-h-[84px] flex-1 flex-col items-center justify-center gap-1.5 rounded-2xl border border-clubhouse-border bg-clubhouse-field px-2 py-3 text-center font-semibold text-clubhouse-muted transition-colors hover:bg-clubhouse-field/70 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-tee-strong"
|
||||||
|
>
|
||||||
|
<Lock aria-hidden="true" className="absolute right-2 top-2 size-3.5 text-clubhouse-muted" />
|
||||||
|
<Icon aria-hidden="true" className="size-5 sm:size-6" />
|
||||||
|
<span className="text-xs font-bold leading-tight text-balance sm:text-sm">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -3,7 +3,7 @@
|
||||||
## Kort sagt
|
## Kort sagt
|
||||||
|
|
||||||
TeeCup er en mobil-først app for golfturneringer og live-scoring — bygget
|
TeeCup er en mobil-først app for golfturneringer og live-scoring — bygget
|
||||||
for golfklubber, bedrifter og vennegjenger som spiller sammen. Navnet
|
for bedrifter og vennegjenger som spiller sammen. Navnet
|
||||||
smelter sammen "Tee" (utslagsstedet, der hvert hull begynner) og "Cup"
|
smelter sammen "Tee" (utslagsstedet, der hvert hull begynner) og "Cup"
|
||||||
(både hullet selv og pokalen konkurransen spilles om) — hele buen i en
|
(både hullet selv og pokalen konkurransen spilles om) — hele buen i en
|
||||||
konkurransedyktig runde, i ett ord.
|
konkurransedyktig runde, i ett ord.
|
||||||
|
|
@ -14,7 +14,7 @@ TeeCup begynte som en avlegger av teeoff.no (banedata/booking), bygget
|
||||||
rundt Ryder Cup-formatet: to lag, kapteiner, matchspill hull mot hull.
|
rundt Ryder Cup-formatet: to lag, kapteiner, matchspill hull mot hull.
|
||||||
Det har siden vokst til en selvstendig, kommersiell produkt med egen
|
Det har siden vokst til en selvstendig, kommersiell produkt med egen
|
||||||
identitet (eget domene, teecup.golf, egen innlogging uavhengig av
|
identitet (eget domene, teecup.golf, egen innlogging uavhengig av
|
||||||
teeoff) — solgt til klubber og bedrifter som kanskje aldri har hørt om
|
teeoff) — solgt til bedrifter og andre som kanskje aldri har hørt om
|
||||||
moderselskapet.
|
moderselskapet.
|
||||||
|
|
||||||
## Hva det er i dag
|
## Hva det er i dag
|
||||||
|
|
@ -36,6 +36,10 @@ moderselskapet.
|
||||||
personlig spillerprofil og historikk.
|
personlig spillerprofil og historikk.
|
||||||
- **Installerbar app (PWA)** — fungerer som en ekte app på telefonen,
|
- **Installerbar app (PWA)** — fungerer som en ekte app på telefonen,
|
||||||
med push-varsler.
|
med push-varsler.
|
||||||
|
- **Turneringsadministrasjon og -presentasjon på PC** — selve
|
||||||
|
scoreføringen er mobil-først, men å sette opp og presentere en
|
||||||
|
turnering fungerer minst like godt fra en datamaskin som fra
|
||||||
|
telefonen.
|
||||||
|
|
||||||
## Personlighet
|
## Personlighet
|
||||||
|
|
||||||
|
|
@ -53,10 +57,10 @@ TeeCup fortsetter å utvide hvor mange måter man kan spille turnering på
|
||||||
— flere formater legges til etter hvert som brukere ber om dem, inkl.
|
— flere formater legges til etter hvert som brukere ber om dem, inkl.
|
||||||
en matchspill-variant av det nyeste laget-mot-enkeltspiller-formatet
|
en matchspill-variant av det nyeste laget-mot-enkeltspiller-formatet
|
||||||
(denne er forresten bygget og live nå). Det skal etter hvert dekke enda
|
(denne er forresten bygget og live nå). Det skal etter hvert dekke enda
|
||||||
mer av det en golfklubb faktisk trenger for å drive turneringer — ikke
|
mer av det en turneringsarrangør faktisk trenger — ikke bare
|
||||||
bare scoreføring, men hele reisen fra oppsett til sesongrangering.
|
scoreføring, men hele reisen fra oppsett til sesongrangering. Målet er
|
||||||
Målet er å være det naturlige valget for klubber og bedrifter som vil
|
å være det naturlige valget for bedrifter og vennegjenger som vil ha
|
||||||
ha noe raskere og triveligere enn tunge, gamle turneringssystemer.
|
noe raskere og triveligere enn tunge, gamle turneringssystemer.
|
||||||
|
|
||||||
## Praktisk for logoen
|
## Praktisk for logoen
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue