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(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(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": [
|
||||
"/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
|
||||
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`
|
||||
og seks nye endepunkter (deltaker-GET/POST, side-GET/POST, DELETE, share),
|
||||
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
|
||||
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:
|
||||
|
||||
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
|
||||
frontend-container/-images) ryddet opp fullstendig etterpå.
|
||||
|
||||
**Ikke rullet ut ennå** — venter på eksplisitt brukerbekreftelse
|
||||
før migrasjon/deploy mot ekte `teecup_db`/`teecup_api`/
|
||||
`teecup_frontend`, samme rutine som punkt 48.
|
||||
**Rullet ut** samme økt, bruker bekreftet eksplisitt ("Ja") — migrasjon
|
||||
061 kjørt mot ekte `teecup_db`, `teecup_api` og `teecup_frontend`
|
||||
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.
|
||||
Nåbar på `teecup.golf/logg-inn` for sammenligning på ekte enhet.
|
||||
|
||||
**Ingen beslutning tatt ennå om å faktisk ta i bruk denne retningen** —
|
||||
kun en parallell sammenligning. Ingen backend-kobling gjort — hvis denne
|
||||
retningen velges, må `teecup-auth.tsx` sine mockede API-kall kobles til
|
||||
de ekte `/auth/request-link`/`/auth/verify-link`/`/auth/login-password`/
|
||||
`/public/tournaments/by-code`-endepunktene (samme kontrakt som
|
||||
`login-form.tsx` allerede bruker) før den kan erstatte dagens `/`.
|
||||
**✅ BESLUTTET OG RULLET UT 2026-08-08:** bruker valgte denne retningen.
|
||||
`teecup-auth.tsx` koblet til de ekte `/auth/request-link`/`/auth/verify-
|
||||
link`/`/auth/login-password`/`/public/tournaments/by-code`-endepunktene
|
||||
(samme kontrakt som gamle `login-form.tsx` brukte). `/logg-inn` er nå den
|
||||
ekte innloggingssiden — `app/page.tsx` (root) er en tynn videresending
|
||||
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,
|
||||
)
|
||||
from ..errors import app_error, translate_db_errors
|
||||
from ..rate_limit import RateLimiter
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
|
@ -81,6 +82,25 @@ _GENERIC_RESPONSE = {
|
|||
"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:
|
||||
"""Generisk SHA-256-hash -- brukt for BÅDE magic-link-tokens og
|
||||
|
|
@ -158,7 +178,7 @@ class MagicLinkRequest(BaseModel):
|
|||
|
||||
|
||||
@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()
|
||||
now = datetime.now(timezone.utc)
|
||||
cooldown_cutoff = now - timedelta(seconds=settings.MAGIC_LINK_COOLDOWN_SECONDS)
|
||||
|
|
@ -369,7 +389,12 @@ class PasswordLoginRequest(BaseModel):
|
|||
|
||||
|
||||
@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()
|
||||
async with plain_connection() as conn:
|
||||
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")
|
||||
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:
|
||||
user_row = await conn.fetchrow(
|
||||
"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,
|
||||
pending: PendingUser = Depends(get_pending_user),
|
||||
) -> 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:
|
||||
method = await conn.fetchval(
|
||||
"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,
|
||||
user: CurrentUser = Depends(get_current_or_enrolling_user),
|
||||
) -> 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 not body.secret or not verify_totp_code(body.secret, body.code.strip()):
|
||||
raise app_error(401, "INVALID_2FA_CODE", "Feil kode.")
|
||||
|
|
@ -904,6 +940,22 @@ async def update_profile(
|
|||
if not updates:
|
||||
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)]
|
||||
values = list(updates.values())
|
||||
values.append(user.user_id)
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@ async def list_team_messages(
|
|||
async def send_team_message(
|
||||
team_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),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> MessageOut:
|
||||
|
|
@ -572,9 +572,9 @@ async def get_feed(
|
|||
@public_router.post("/{tournament_id}/feed", response_model=MessageOut, status_code=201)
|
||||
async def post_to_feed(
|
||||
tournament_id: str,
|
||||
body: str | None = Form(default=None),
|
||||
body: str | None = Form(default=None, max_length=2000),
|
||||
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),
|
||||
) -> MessageOut:
|
||||
if not body and image is None:
|
||||
|
|
|
|||
|
|
@ -24,15 +24,16 @@ router = APIRouter()
|
|||
|
||||
class PlayerCreate(BaseModel):
|
||||
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]$")
|
||||
mobile: str | None = None
|
||||
mobile: str | None = Field(default=None, max_length=30)
|
||||
email: EmailStr | None = None
|
||||
birth_date: date | None = None
|
||||
nickname: str | None = None
|
||||
country: str | None = None
|
||||
club: str | None = None
|
||||
club_member_number: str | None = None
|
||||
nickname: str | None = Field(default=None, max_length=100)
|
||||
country: str | None = Field(default=None, max_length=100)
|
||||
club: str | None = Field(default=None, max_length=200)
|
||||
club_member_number: str | None = Field(default=None, max_length=50)
|
||||
|
||||
|
||||
class Player(BaseModel):
|
||||
|
|
@ -108,15 +109,15 @@ class PlayerUpdate(BaseModel):
|
|||
"""
|
||||
|
||||
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]$")
|
||||
mobile: str | None = None
|
||||
mobile: str | None = Field(default=None, max_length=30)
|
||||
email: EmailStr | None = None
|
||||
birth_date: date | None = None
|
||||
nickname: str | None = None
|
||||
country: str | None = None
|
||||
club: str | None = None
|
||||
club_member_number: str | None = None
|
||||
nickname: str | None = Field(default=None, max_length=100)
|
||||
country: str | None = Field(default=None, max_length=100)
|
||||
club: str | None = Field(default=None, max_length=200)
|
||||
club_member_number: str | None = Field(default=None, max_length=50)
|
||||
|
||||
|
||||
@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 fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .. import storage
|
||||
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)
|
||||
async def post_round_message(
|
||||
round_id: str,
|
||||
body: str | None = Form(default=None),
|
||||
body: str | None = Form(default=None, max_length=2000),
|
||||
image: UploadFile | None = File(default=None),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> RoundMessageOut:
|
||||
|
|
@ -314,7 +314,7 @@ _ROUND_MESSAGE_COMMENT_COLUMNS = """
|
|||
|
||||
|
||||
class CommentIn(BaseModel):
|
||||
body: str
|
||||
body: str = Field(min_length=1, max_length=2000)
|
||||
parent_comment_id: str | None = None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -114,18 +114,20 @@ class PersonalCourseHoleIn(BaseModel):
|
|||
|
||||
class PersonalCourseTeeRatingIn(BaseModel):
|
||||
gender: Literal["m", "f"]
|
||||
course_rating: float
|
||||
course_rating: float = Field(ge=50, le=90)
|
||||
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):
|
||||
name: str
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
ratings: list[PersonalCourseTeeRatingIn] = Field(min_length=1, max_length=2)
|
||||
|
||||
|
||||
class PersonalCourseCreate(BaseModel):
|
||||
name: str
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18)
|
||||
tees: list[PersonalCourseTeeIn] = Field(min_length=1)
|
||||
# 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),
|
||||
) -> BBBHoleOut:
|
||||
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(
|
||||
"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":
|
||||
raise app_error(400, "VALIDATION_FAILED", "Denne runden bruker ikke Bingo Bango Bongo.")
|
||||
if not (1 <= hole_number <= round_row["holes_planned"]):
|
||||
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):
|
||||
if pid is None:
|
||||
|
|
|
|||
|
|
@ -47,8 +47,12 @@ export default async function LoggInnPage() {
|
|||
// (en catch der ville slukt den stille og latt innloggingsskjemaet vises
|
||||
// i stedet for å omdirigere, reell bug funnet under scratch-verifisering
|
||||
// 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) {
|
||||
redirect(profileComplete ? "/dashboard" : "/account")
|
||||
redirect(profileComplete ? "/dashboard" : "/velkommen")
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -39,13 +39,16 @@ export default async function Page() {
|
|||
}
|
||||
// Obligatorisk profil-fullføring (2026-07-22): en innlogget bruker som
|
||||
// ennå ikke har fylt ut de obligatoriske profilfeltene sendes til
|
||||
// /account (som selv viser en fokusert fullførings-visning) i stedet
|
||||
// for dashbordet -- se AccountSettings sin profile_complete-gren.
|
||||
// /velkommen (ikke rett til /account lenger, se ARCHITECTURE_DECISIONS.md
|
||||
// -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
|
||||
// CHANGELOG.md) -- denne siden (root) er nå bare en tynn videresending,
|
||||
// beholdt for gamle bokmerker/lenker til teecup.golf/. Selve
|
||||
// innloggingsskjemaet (LoginForm, Forest Green) er fjernet -- erstattet
|
||||
// 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.
|
||||
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 }) {
|
||||
return (
|
||||
<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 [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 =
|
||||
firstName.trim() !== "" &&
|
||||
lastName.trim() !== "" &&
|
||||
birthDate !== "" &&
|
||||
!underMinimumAge &&
|
||||
gender !== "" &&
|
||||
hcp.trim() !== "" &&
|
||||
homeClub.trim() !== "" &&
|
||||
|
|
@ -484,8 +506,14 @@ function ProfileOnboarding({ me, onComplete }: { me: Me; onComplete: () => void
|
|||
type="date"
|
||||
value={birthDate}
|
||||
onChange={(e) => setBirthDate(e.target.value)}
|
||||
aria-invalid={underMinimumAge}
|
||||
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 className="flex flex-col gap-1.5">
|
||||
<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 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) {
|
||||
e.preventDefault()
|
||||
if (underMinimumAge) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
|
|
@ -722,8 +754,14 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
|||
type="date"
|
||||
value={birthDate}
|
||||
onChange={(e) => setBirthDate(e.target.value)}
|
||||
aria-invalid={underMinimumAge}
|
||||
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 className="flex flex-col gap-1.5">
|
||||
<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>}
|
||||
{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"}
|
||||
</Button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -311,8 +311,10 @@ export function Dashboard() {
|
|||
return
|
||||
}
|
||||
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) {
|
||||
router.replace("/account")
|
||||
router.replace("/velkommen")
|
||||
return
|
||||
}
|
||||
setMe(data)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,23 @@ function isIOS() {
|
|||
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() {
|
||||
if (typeof window === "undefined") return true
|
||||
const raw = window.localStorage.getItem(DISMISS_KEY)
|
||||
|
|
@ -41,17 +58,20 @@ function dismissForNow() {
|
|||
// reell installasjonsvei:
|
||||
// - Android/Chrome-familien: ekte "Installer"-knapp via det fangede
|
||||
// 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.
|
||||
export function InstallPrompt() {
|
||||
const [dismissed, setDismissed] = useState(true)
|
||||
const [platform, setPlatform] = useState<"android" | "ios" | "none">("none")
|
||||
const [iosBrowserKind, setIosBrowserKind] = useState<"safari" | "chrome" | "other">("other")
|
||||
const [deferredEvent, setDeferredEvent] = useState<BeforeInstallPromptEvent | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isStandalone() || isDismissedForNow()) return
|
||||
if (isIOS()) {
|
||||
setPlatform("ios")
|
||||
setIosBrowserKind(iosBrowser())
|
||||
setDismissed(false)
|
||||
return
|
||||
}
|
||||
|
|
@ -117,8 +137,13 @@ export function InstallPrompt() {
|
|||
</h2>
|
||||
{platform === "ios" ? (
|
||||
<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
|
||||
Safari, velg deretter <SquarePlus aria-hidden="true" className="inline size-4 -translate-y-0.5" />{" "}
|
||||
Trykk <Share aria-hidden="true" className="inline size-4 -translate-y-0.5" /> (Del){" "}
|
||||
{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».
|
||||
</p>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -2049,6 +2049,10 @@ type ShotRecord = {
|
|||
club: string
|
||||
distance_meters: number
|
||||
shared_round_message_id: string | null
|
||||
start_lat: number
|
||||
start_lng: number
|
||||
end_lat: number
|
||||
end_lng: number
|
||||
}
|
||||
|
||||
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({
|
||||
shots,
|
||||
onDelete,
|
||||
|
|
@ -2302,9 +2317,19 @@ function ShotList({
|
|||
onShare?: (shot: ShotRecord) => void
|
||||
}) {
|
||||
return (
|
||||
<ul className="flex w-full min-w-[14rem] flex-col gap-1 rounded-xl border border-border bg-card p-1.5">
|
||||
{shots.map((shot) => (
|
||||
<li key={shot.id} className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm">
|
||||
<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) => {
|
||||
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">
|
||||
{shot.club} <span className="tabular-nums text-muted-foreground">— {shot.distance_meters} m</span>
|
||||
</span>
|
||||
|
|
@ -2336,7 +2361,8 @@ function ShotList({
|
|||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,32 +3,47 @@
|
|||
import { useEffect, useRef, useState } from "react"
|
||||
import mapboxgl from "mapbox-gl"
|
||||
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 }
|
||||
|
||||
/**
|
||||
* MapPointPicker
|
||||
*
|
||||
* Rendered ONLY when the user chooses "Velg punkt på kart", and always via
|
||||
* next/dynamic(..., { ssr: false }) from the parent. It mounts once per
|
||||
* sheet-open (once per step, since start and end use separate mounted
|
||||
* instances) and stays mounted until that step is left, so the Mapbox map is
|
||||
* created exactly once (empty-deps useEffect) — taps, pans and marker drags
|
||||
* never re-initialise it. This keeps Mapbox map loads (which are billed) to a
|
||||
* single load per step-open.
|
||||
* To ulike modi, styrt av `referencePoint`:
|
||||
* - Start-steget (referencePoint IKKE satt): rendret kun når brukeren
|
||||
* velger "Velg punkt på kart". Trykk-for-å-plassere en markør, ett
|
||||
* engangs-GPS-oppslag kun for sentrering.
|
||||
* - Ball-steget (referencePoint satt, ADR-048/049-tillegg): rendres
|
||||
* ALLTID. Rent informativt kart (referansepunkt + løpende sporet
|
||||
* 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({
|
||||
onConfirm,
|
||||
referencePoint,
|
||||
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,
|
||||
* ikke-flyttbart referansepunkt (utslagspunktet) vises på kartet, og
|
||||
* kartet forsøker å vise BÅDE dette og brukerens nåværende posisjon i
|
||||
* samme bilde -- brukerønske: "slagpunktet må være en del av det jeg ser".
|
||||
* Satt KUN på ballposisjon-steget: et fast, ikke-flyttbart
|
||||
* referansepunkt (utslagspunktet) vises på kartet, og kartet forsøker å
|
||||
* vise BÅDE dette og brukerens nåværende posisjon i samme bilde --
|
||||
* 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
|
||||
referenceLabel?: string
|
||||
|
|
@ -37,10 +52,17 @@ export function MapPointPicker({
|
|||
const mapRef = useRef<mapboxgl.Map | null>(null)
|
||||
const markerRef = 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 [point, setPoint] = useState<LngLat | 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(() => {
|
||||
const token = process.env.NEXT_PUBLIC_MAPBOX_TOKEN
|
||||
|
|
@ -48,7 +70,7 @@ export function MapPointPicker({
|
|||
if (!container) return
|
||||
|
||||
if (!token) {
|
||||
setError("Kartet er ikke tilgjengelig akkurat nå. Bruk «Min posisjon nå» i stedet.")
|
||||
setError("Kartet er ikke tilgjengelig akkurat nå.")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -58,9 +80,9 @@ export function MapPointPicker({
|
|||
// Oslo-koordinat -- en kommentar her hevdet "real app centers on
|
||||
// last-known position", men den logikken fantes aldri, kun påstanden.
|
||||
// Henter nå brukerens faktiske GPS-posisjon FØR kartet initialiseres
|
||||
// (kun ETT kall, samme som "Min posisjon nå"-veien -- ingen løpende
|
||||
// watchPosition), med Oslo som fallback KUN hvis posisjon ikke kan
|
||||
// hentes (avslått tillatelse, tidsavbrudd, ingen støtte).
|
||||
// (ett engangs-oppslag for selve sentreringen), med Oslo som fallback
|
||||
// KUN hvis posisjon ikke kan hentes (avslått tillatelse, tidsavbrudd,
|
||||
// ingen støtte).
|
||||
// fitTo: satt når vi HAR en brukerposisjon OG et referansepunkt --
|
||||
// kartet zoomer/panorerer da til å vise begge (padding rundt), i stedet
|
||||
// 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.
|
||||
// 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) => {
|
||||
const lngLat = { lng: e.lngLat.lng, lat: e.lngLat.lat }
|
||||
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)
|
||||
.addTo(map)
|
||||
marker.on("dragend", () => {
|
||||
|
|
@ -114,6 +143,7 @@ export function MapPointPicker({
|
|||
setPoint(lngLat)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const OSLO_FALLBACK: [number, number] = [10.7522, 59.9139]
|
||||
const fallbackCenter: [number, number] = referencePoint
|
||||
|
|
@ -136,8 +166,46 @@ export function MapPointPicker({
|
|||
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 () => {
|
||||
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 = null
|
||||
markerRef.current?.remove()
|
||||
|
|
@ -190,25 +258,56 @@ export function MapPointPicker({
|
|||
</div>
|
||||
) : 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">
|
||||
<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" />
|
||||
{referencePoint ? "Trykk der ballen ligger" : "Trykk på kartet for å plassere punktet"}
|
||||
Trykk på kartet for å plassere punktet
|
||||
</p>
|
||||
</div>
|
||||
) : 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 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
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
{referencePoint ? "Bekreft ballens posisjon" : "Bekreft punkt"}
|
||||
Bekreft punkt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Crosshair, LocateFixed, MapPin, X, ChevronLeft, Check } from "lucide-re
|
|||
import { ClubPicker } from "@/components/teecup/club-picker"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { haversineMeters } from "@/lib/geo"
|
||||
|
||||
/**
|
||||
* 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 Step = "start" | "map" | "end" | "end_map" | "club" | "result"
|
||||
type Step = "start" | "map" | "end" | "club" | "result"
|
||||
type GeoStatus = "idle" | "loading" | "error"
|
||||
|
||||
export type ShotMeasurementSheetProps = {
|
||||
|
|
@ -63,15 +64,7 @@ export type ShotMeasurementSheetProps = {
|
|||
// The real app passes distanceMeters computed server-/caller-side.
|
||||
function previewDistance(a: LngLat | null, b: LngLat | null): number {
|
||||
if (!a || !b) return 0
|
||||
const R = 6371000
|
||||
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)))
|
||||
return Math.round(haversineMeters(a, b))
|
||||
}
|
||||
|
||||
export function ShotMeasurementSheet({
|
||||
|
|
@ -91,7 +84,6 @@ export function ShotMeasurementSheet({
|
|||
const [endPoint, setEndPoint] = useState<LngLat | null>(null)
|
||||
|
||||
const [startStatus, setStartStatus] = useState<GeoStatus>("idle")
|
||||
const [endStatus, setEndStatus] = useState<GeoStatus>("idle")
|
||||
|
||||
const [club, setClub] = useState<string>("")
|
||||
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 =
|
||||
typeof distanceMeters === "number" ? distanceMeters : previewDistance(startPoint, endPoint)
|
||||
|
||||
|
|
@ -167,11 +140,7 @@ export function ShotMeasurementSheet({
|
|||
function goBack() {
|
||||
if (step === "map" || step === "end") {
|
||||
setStartStatus("idle")
|
||||
setEndStatus("idle")
|
||||
setStep("start")
|
||||
} else if (step === "end_map") {
|
||||
setEndStatus("idle")
|
||||
setStep("end")
|
||||
} else if (step === "club") {
|
||||
// Re-measure the ball position rather than reusing a stale fix.
|
||||
setEndPoint(null)
|
||||
|
|
@ -198,7 +167,7 @@ export function ShotMeasurementSheet({
|
|||
})
|
||||
}
|
||||
|
||||
const onMapStep = step === "map" || step === "end_map"
|
||||
const onMapStep = step === "map" || step === "end"
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -308,100 +277,27 @@ export function ShotMeasurementSheet({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* method-argumentet i onConfirm ignoreres bevisst her -- alltid
|
||||
"map_tap" på start-steget siden ingen referencePoint er satt. */}
|
||||
{step === "map" ? <MapPointPicker onConfirm={(p) => {
|
||||
setStartPoint(p)
|
||||
setStep("end")
|
||||
}} /> : 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" ? (
|
||||
<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
|
||||
referencePoint={startPoint ?? undefined}
|
||||
referenceLabel="Utslag"
|
||||
onConfirm={(p) => {
|
||||
onConfirm={(p, method) => {
|
||||
setEndPoint(p)
|
||||
setEndMethod(method)
|
||||
setStep("club")
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,22 @@ function isActive(pathname: string, href: string) {
|
|||
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() ?? ""
|
||||
|
||||
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)]">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const disabled = disabledHrefs?.includes(tab.href) ?? false
|
||||
const active =
|
||||
isActive(pathname, tab.href) || (tab.matchPaths?.some((p) => isActive(pathname, p)) ?? false)
|
||||
return (
|
||||
<li key={tab.href} className="flex-1">
|
||||
<Link
|
||||
href={tab.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
!disabled &&
|
||||
(isActive(pathname, tab.href) || (tab.matchPaths?.some((p) => isActive(pathname, p)) ?? false))
|
||||
const itemClassName = 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",
|
||||
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tee-strong",
|
||||
active
|
||||
disabled
|
||||
? "text-neutral-400"
|
||||
: active
|
||||
? "text-tee-strong"
|
||||
: "text-neutral-500 hover:text-neutral-900",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
)
|
||||
const iconWrapClassName = cn(
|
||||
"relative flex h-8 w-12 items-center justify-center rounded-full transition-colors",
|
||||
active
|
||||
? "bg-tee/20"
|
||||
: "bg-transparent group-hover:bg-black/[0.04]",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className="h-[22px] w-[22px]"
|
||||
strokeWidth={active ? 2.6 : 2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
active ? "bg-tee/20" : "bg-transparent group-hover:bg-black/[0.04]",
|
||||
)
|
||||
const inner = (
|
||||
<>
|
||||
<span className={iconWrapClassName}>
|
||||
<Icon className="h-[22px] w-[22px]" strokeWidth={active ? 2.6 : 2} aria-hidden="true" />
|
||||
{typeof tab.badge === "number" && tab.badge > 0 && (
|
||||
<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"
|
||||
|
|
@ -109,7 +117,24 @@ export function BottomNav({ tabs = TABS }: { tabs?: Tab[] }) {
|
|||
<span className="sr-only">{`, ${tab.badge} nye`}</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>
|
||||
)}
|
||||
</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
|
||||
|
||||
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"
|
||||
(både hullet selv og pokalen konkurransen spilles om) — hele buen i en
|
||||
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.
|
||||
Det har siden vokst til en selvstendig, kommersiell produkt med egen
|
||||
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.
|
||||
|
||||
## Hva det er i dag
|
||||
|
|
@ -36,6 +36,10 @@ moderselskapet.
|
|||
personlig spillerprofil og historikk.
|
||||
- **Installerbar app (PWA)** — fungerer som en ekte app på telefonen,
|
||||
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
|
||||
|
||||
|
|
@ -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.
|
||||
en matchspill-variant av det nyeste laget-mot-enkeltspiller-formatet
|
||||
(denne er forresten bygget og live nå). Det skal etter hvert dekke enda
|
||||
mer av det en golfklubb faktisk trenger for å drive turneringer — ikke
|
||||
bare scoreføring, men hele reisen fra oppsett til sesongrangering.
|
||||
Målet er å være det naturlige valget for klubber og bedrifter som vil
|
||||
ha noe raskere og triveligere enn tunge, gamle turneringssystemer.
|
||||
mer av det en turneringsarrangør faktisk trenger — ikke bare
|
||||
scoreføring, men hele reisen fra oppsett til sesongrangering. Målet er
|
||||
å være det naturlige valget for bedrifter og vennegjenger som vil ha
|
||||
noe raskere og triveligere enn tunge, gamle turneringssystemer.
|
||||
|
||||
## Praktisk for logoen
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue