Update Todos
Migrasjon 015: app_user-profilfelt + player_organizations_for_user()-bro Backend: utvid Me + PATCH /auth/profile + avatar-opplasting/sletting Backend: 'mine runder'-data + check_visibility-utvidelse for deltakere Frontend: profil-seksjon i /account Frontend: 'Mine runder'-seksjon + betinget tom-tilstand i dashboard.tsx Scratch-verifisere alt (15 sjekker bestått) Typesjekket frontend-build ADR-031 + .md-oppdatering Bygget og scratch-verifisert (15 automatiserte sjekker). Oppsummering: Personlig profil — nye felt på selve kontoen (ikke på org-ens spillerdata, det er bevisst holdt atskilt siden en person kan ha ulike spiller-rader i ulike klubber): profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb. Redigeres i en ny seksjon på /account. Sletting av enkeltfelt fungerer (send tomt/null), profilbilde kan lastes opp og fjernes. "Mine runder" — ny seksjon øverst på dashbordet, viser turneringer du er rostret i på tvers av alle organisasjoner, uavhengig av om du er medlem noe sted. Et reelt sikkerhetshull jeg fant underveis, ikke antatt på forhånd: da jeg testet "Mine runder" mot en faktisk ren spiller (ingen organisasjonsmedlemskap), oppdaget jeg at synlighetsregelen kun ga deltakere tilgang for det strengeste synlighetsnivået — ikke for standard-nivået («org»), som er det ALLE nye turneringer får automatisk. En ren spiller ville altså vært låst ute av sin egen, helt normale turnering. Fikset og verifisert grundig at det er en ren utvidelse: en fremmed innlogget bruker og en anonym leser blir fortsatt korrekt avvist som før. Bevisst utenfor omfang, tydelig flagget: "Mine runder" lenker til den offentlige turnering-siden, ikke til lagets private chat eller scorekortet ennå — de krever fortsatt ekte organisasjonsmedlemskap, en strengere sperre brukt bredt i hele appen som jeg ikke ville endre uten en egen, forsiktig runde. Notert som naturlig neste steg. Ingen kode for punkt 2 (midlertidige spillere) i denne runden, som avtalt.
This commit is contained in:
parent
f9c3050583
commit
3dd6224bfd
9 changed files with 820 additions and 44 deletions
|
|
@ -327,7 +327,8 @@
|
||||||
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_hcp_fix.py)",
|
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_hcp_fix.py)",
|
||||||
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_tee_gender.py)",
|
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_tee_gender.py)",
|
||||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teeoff.no)",
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teeoff.no)",
|
||||||
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_player_edit_and_dates.py)"
|
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_player_edit_and_dates.py)",
|
||||||
|
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_profile_and_myrounds.py)"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
37
015_user_profile.sql
Normal file
37
015_user_profile.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — personlig profil på app_user + tverr-org spiller-oppslag
|
||||||
|
-- (migrasjon 015)
|
||||||
|
-- =====================================================================
|
||||||
|
-- ADR-031: personlig landingsside for enhver registrert bruker. Ett
|
||||||
|
-- canonical profil-sett PER KONTO (ikke per org-scopet `player`-rad --
|
||||||
|
-- se ADR-031 for hvorfor de to bevisst holdes atskilt). Alt additivt/
|
||||||
|
-- nullable -- trygt mot eksisterende kontoer.
|
||||||
|
-- =====================================================================
|
||||||
|
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE app_user ADD COLUMN first_name text;
|
||||||
|
ALTER TABLE app_user ADD COLUMN last_name text;
|
||||||
|
ALTER TABLE app_user ADD COLUMN birth_date date;
|
||||||
|
ALTER TABLE app_user ADD COLUMN gender text CHECK (gender IN ('m', 'f', 'x'));
|
||||||
|
ALTER TABLE app_user ADD COLUMN handicap_index numeric(4,1);
|
||||||
|
ALTER TABLE app_user ADD COLUMN home_club text;
|
||||||
|
ALTER TABLE app_user ADD COLUMN avatar_key text;
|
||||||
|
|
||||||
|
-- Samme "smale SECURITY DEFINER-bro"-mønster som public_tournament_org()
|
||||||
|
-- (007) / link_player_by_email() (008) / public_org_by_slug() (009) /
|
||||||
|
-- public_tournament_by_code() (011): `player` er RLS-beskyttet per org, så
|
||||||
|
-- et tverr-org-oppslag ("hvilke organisasjoner har jeg en spiller-rad i")
|
||||||
|
-- kan ikke gjøres med en vanlig org-scopet tilkobling. Eksponerer KUN
|
||||||
|
-- uuid-listen, ingenting annet fra player-raden.
|
||||||
|
CREATE FUNCTION player_organizations_for_user(p_user_id uuid)
|
||||||
|
RETURNS TABLE(organization_id uuid)
|
||||||
|
LANGUAGE sql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = public
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT DISTINCT organization_id FROM player WHERE user_id = p_user_id;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION player_organizations_for_user(uuid) TO teecup_app;
|
||||||
|
|
@ -1449,6 +1449,92 @@ migrasjon. Verifisert mot ekte data: "De Gamle er Eldst" viser nå korrekt
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-031: Personlig landingsside for enhver registrert bruker + personlig profil
|
||||||
|
|
||||||
|
Reist av brukeren 2026-07-20, eksplisitt som en "tenk igjennom og foreslå"-
|
||||||
|
instruks, deretter et klart "gjør det" med et utvidet omfang (personlig
|
||||||
|
profil-CRUD: profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP,
|
||||||
|
hjemmeklubb).
|
||||||
|
|
||||||
|
**Bekreftet, reelt hull:** `app/page.tsx` sendte enhver innlogget bruker til
|
||||||
|
`/dashboard`, som viste "opprett organisasjon" så snart brukeren ikke var
|
||||||
|
org-medlem — også for en bruker som KUN er spiller (koblet via
|
||||||
|
`player.user_id`, ADR-017 B), aldri organisator.
|
||||||
|
|
||||||
|
**Beslutning A — ETT samlet dashboard, ikke to atskilte ruter.** `/dashboard`
|
||||||
|
viser nå en ny "Mine runder"-seksjon øverst (turneringer brukeren er ROSTRET
|
||||||
|
i, på tvers av organisasjoner) når den finnes, med organisasjonsseksjonen
|
||||||
|
uendret under. En bruker som er BÅDE organisator og spiller ser begge deler
|
||||||
|
— ingen tvungen valg mellom to identiteter.
|
||||||
|
|
||||||
|
**Beslutning B — personlig profil er ETT sett PER KONTO (`app_user`), atskilt
|
||||||
|
fra org-scopede `player`-rader.** Ny migrasjon `015_user_profile.sql`:
|
||||||
|
`app_user` får `first_name`/`last_name`/`birth_date`/`gender`/
|
||||||
|
`handicap_index`/`home_club`/`avatar_key`. Bevisst IKKE forsøkt slått sammen
|
||||||
|
med `player`-radene (som fortsatt er per-organisasjon, eid av organisator,
|
||||||
|
brukt til roster/handicap-snapshot) — en person kan ha flere `player`-rader
|
||||||
|
i ulike klubber (ulikt hjemmeklubb-medlemsnummer, potensielt ulik registrert
|
||||||
|
HCP per klubb i den virkelige golfverdenen), mens KONTOENS egen profil er
|
||||||
|
brukerens EGEN, selvstyrte fremstilling av seg selv — to bevisst atskilte
|
||||||
|
konsepter, ikke ett duplisert.
|
||||||
|
|
||||||
|
**Konsekvens:** `PATCH /auth/profile` (vanlig `exclude_unset`-PATCH-mønster
|
||||||
|
— et felt sendt eksplisitt som `null` sletter det, et utelatt felt endres
|
||||||
|
ikke), `POST`/`DELETE /auth/profile/avatar` (samme ekte multipart→AVIF-
|
||||||
|
opplastingsmønster som `tournament.hero_image_key`, ADR-018 MinIO-runden —
|
||||||
|
se `app/storage.py`). Ny seksjon i `/account` (`account-settings.tsx`).
|
||||||
|
|
||||||
|
**Beslutning C — "Mine runder" krever et NYTT tverr-org-oppslag, samme
|
||||||
|
mønster som fire tidligere bygde bruksområder.** `player` er RLS-beskyttet
|
||||||
|
per organisasjon; å finne "hvilke org-er har jeg en spiller-rad i" krever
|
||||||
|
samme smale `SECURITY DEFINER`-bro som `public_tournament_org()` (007)/
|
||||||
|
`link_player_by_email()` (008)/`public_org_by_slug()` (009)/
|
||||||
|
`public_tournament_by_code()` (011) — ny `player_organizations_for_user()`
|
||||||
|
(migrasjon 015), eksponerer KUN en uuid-liste. `/auth/me` utvidet med
|
||||||
|
`my_tournaments` (samme N+1-over-`org_connection()`-mønster som allerede
|
||||||
|
brukes for `organizations`).
|
||||||
|
|
||||||
|
**Beslutning D — ny sikkerhetsutvidelse funnet UNDER design, ikke antatt på
|
||||||
|
forhånd: en faktisk deltaker skal aldri stenges ute av sin EGEN turnering,
|
||||||
|
uansett synlighetsnivå.** Under bygging av "Mine runder" ble det klart at
|
||||||
|
`check_visibility()` (ADR-018 Beslutning C) kun ga deltaker-tilgang for
|
||||||
|
`visibility='participants'` — IKKE for `'org'` (som er DEFAULT for enhver
|
||||||
|
NY turnering). En ren spiller (rostret, men uten organisasjonsmedlemskap)
|
||||||
|
ville dermed vært stengt ute fra sin egen, helt vanlige turnering (default
|
||||||
|
`'org'`-synlighet) — nøyaktig den brukergruppen "Mine runder" er bygget
|
||||||
|
for. Utvidet: deltaker-sjekken gjelder nå for BEGGE ikke-offentlige tiere,
|
||||||
|
ikke bare `'participants'`. Begrunnelse: `visibility` styrer eksponering
|
||||||
|
mot UTENFORSTÅENDE, aldri mot folk som faktisk spiller i turneringen — det
|
||||||
|
finnes intet scenario der en organisator ønsker å skjule en turnering for
|
||||||
|
sin EGEN spiller. Verifisert presist: en faktisk deltaker (ikke org-medlem)
|
||||||
|
FÅR nå tilgang til en `'org'`-synlig turnering, mens en helt ubeslektet
|
||||||
|
FREMMED (innlogget, men ikke deltaker) og en ANONYM leser fortsatt begge
|
||||||
|
avvises som før (403 `NOT_VISIBLE`) — ren utvidelse, ingen innstramming.
|
||||||
|
|
||||||
|
**Bevisst UTENFOR omfang denne runden, kjent gjenstående begrensning:**
|
||||||
|
"Mine runder" lenker til den offentlige turnering-siden (`/t/{id}`), IKKE
|
||||||
|
til lagets private chat eller det organisator-vendte scorekortet — disse
|
||||||
|
krever fortsatt `get_authorized_org` (ekte organisasjonsmedlemskap), en
|
||||||
|
strengere sperre enn deltaker-status alene, brukt av dusinvis av
|
||||||
|
endepunkter på tvers av hele appen. Å utvide DENNE sperren trygt til også å
|
||||||
|
godta "faktisk deltaker" er en egen, større og mer risikofylt endring
|
||||||
|
(påvirker autorisasjonsarkitekturen bredt) — bevisst IKKE gjort i denne
|
||||||
|
runden, notert i FEATURE_BACKLOG.md som naturlig neste steg.
|
||||||
|
|
||||||
|
**Scratch-verifisert, 15 sjekker:** profil-CRUD (sett alle felt, delvis
|
||||||
|
PATCH lar andre felt stå urørt, eksplisitt `null` sletter et felt, tomt
|
||||||
|
PATCH avvist, avatar lastet opp med ekte AVIF-URL, avatar slettet, ugyldig
|
||||||
|
filtype avvist), "Mine runder" for en EKTE ren spiller (null organisasjons-
|
||||||
|
medlemskap, men `my_tournaments` viser riktig turnering+lag), OG den
|
||||||
|
kritiske sikkerhetssjekken: samme rene spiller FÅR nå se sin `'org'`-
|
||||||
|
synlige turnering, mens en fremmed innlogget bruker og en anonym begge
|
||||||
|
fortsatt avvises. Ekte typesjekket produksjonsbuild kjørt og bekreftet.
|
||||||
|
|
||||||
|
**Status: ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-20, IKKE ENNÅ RULLET UT.**
|
||||||
|
Se CLAUDE.md-status for full byggerunde.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Åpne spørsmål (ikke besluttet ennå)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
|
||||||
107
CLAUDE.md
107
CLAUDE.md
|
|
@ -1941,31 +1941,114 @@ Ferdig og verifisert:
|
||||||
upåvirket. Verifisert mot EKTE data (ikke bare scratch): "De Gamle er
|
upåvirket. Verifisert mot EKTE data (ikke bare scratch): "De Gamle er
|
||||||
Eldst" viser nå korrekt 11. juli 2026 i stedet for "Ingen datoer satt".
|
Eldst" viser nå korrekt 11. juli 2026 i stedet for "Ingen datoer satt".
|
||||||
|
|
||||||
|
- **To nye punkter reist 2026-07-20, GJENNOMTENKT OG FORESLÅTT, IKKE
|
||||||
|
bygget:** brukeren ba eksplisitt om at punkt 1 tenkes grundig gjennom
|
||||||
|
og legges frem som et forslag FØR bygging (ikke kode med en gang), og
|
||||||
|
markerte det eksplisitt som prioritet over punkt 2.
|
||||||
|
1. **Personlig landingsside for enhver registrert bruker (PRIORITERT).**
|
||||||
|
Bekreftet reelt hull ved kodegjennomgang: `app/page.tsx` sender
|
||||||
|
enhver innlogget bruker til `/dashboard`, som viser "opprett
|
||||||
|
organisasjon" så snart `organizations.length === 0` — også for en
|
||||||
|
bruker som KUN er spiller (koblet via `player.user_id`,
|
||||||
|
ADR-017 B), aldri organisator. Fullt forslag skrevet i
|
||||||
|
FEATURE_BACKLOG.md: ett samlet dashboard (ikke to atskilte ruter),
|
||||||
|
ny "Mine runder"-seksjon (tverr-org, krever en ny SECURITY
|
||||||
|
DEFINER-bro `player_organizations_for_user()` + utvidelse av
|
||||||
|
`/auth/me`, samme mønster som `public_tournament_org()` m.fl.),
|
||||||
|
organisasjonsseksjonen uendret under. Venter på brukerens
|
||||||
|
bekreftelse på retningen før bygging starter.
|
||||||
|
2. **Midlertidige spillere + automatisk etter-runde-e-post (lavere
|
||||||
|
prioritet, likevel dokumentert grundig).** Presisert ved
|
||||||
|
kodegjennomgang: det meste av "midlertidig spiller"-behovet
|
||||||
|
dekkes ALLEREDE av eksisterende `POST /orgs/{id}/players`
|
||||||
|
(krever aldri en konto). Det som faktisk mangler er en PROAKTIV
|
||||||
|
e-post-utsending etter runden (scorekort + innloggingslenke) —
|
||||||
|
foreslått som en eksplisitt organisator-knapp per økt (ikke en
|
||||||
|
automatisk bakgrunnsjobb, for å unngå uventede e-poster fra en
|
||||||
|
gjettet "runden er ferdig"-deteksjon). Tre åpne spørsmål notert i
|
||||||
|
FEATURE_BACKLOG.md (økt- vs. turnering-nivå, dobbel-utsending-
|
||||||
|
sperre, locale).
|
||||||
|
**Ingen kode skrevet for noen av de to ennå** — dette var bevisst en
|
||||||
|
tenke-og-foreslå-runde, ikke en byggerunde.
|
||||||
|
|
||||||
|
- **Punkt 1 BYGGET OG SCRATCH-VERIFISERT (2026-07-20), samme dag, rett etter
|
||||||
|
forslaget over:** bruker svarte "Gjør punkt 1" med et utvidet omfang —
|
||||||
|
inkluder også opprettelse/redigering/sletting av personlig informasjon
|
||||||
|
(profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb).
|
||||||
|
**Ny migrasjon `015_user_profile.sql`:** `app_user` får de sju nye
|
||||||
|
profilfeltene — ETT sett PER KONTO, bevisst IKKE slått sammen med de
|
||||||
|
org-scopede `player`-radene (se ADR-031 Beslutning B for full
|
||||||
|
begrunnelse — to reelt atskilte konsepter). Ny
|
||||||
|
`player_organizations_for_user()`-bro (femte instans av samme
|
||||||
|
SECURITY DEFINER-mønster som `public_tournament_org()` m.fl.).
|
||||||
|
**Backend:** `/auth/me` utvidet med profilfeltene + `avatar_url` +
|
||||||
|
`my_tournaments` (turneringer brukeren er ROSTRET i, tverr-org, samme
|
||||||
|
N+1-org_connection()-mønster som organisasjonslisten). Ny
|
||||||
|
`PATCH /auth/profile` (vanlig exclude_unset), `POST`/
|
||||||
|
`DELETE /auth/profile/avatar` (samme ekte multipart→AVIF-mønster som
|
||||||
|
turnering-hero-bilder).
|
||||||
|
**Reelt sikkerhetshull funnet UNDER bygging, ikke antatt på forhånd:**
|
||||||
|
testet "Mine runder" mot en EKTE ren spiller (rostret, ingen
|
||||||
|
org-medlemskap) og oppdaget at `check_visibility()` (ADR-018) kun ga
|
||||||
|
deltaker-tilgang for `visibility='participants'` — IKKE for `'org'`
|
||||||
|
(DEFAULT for enhver ny turnering). En ren spiller ville altså vært
|
||||||
|
stengt ute fra sin EGEN, helt vanlige turnering — nøyaktig
|
||||||
|
brukergruppen "Mine runder" er bygget for. Fikset: deltaker-sjekken
|
||||||
|
gjelder nå begge ikke-offentlige tier, med eksplisitt begrunnelse om
|
||||||
|
at visibility styrer eksponering mot UTENFORSTÅENDE, aldri mot faktiske
|
||||||
|
deltakere. Verifisert presist at dette er en REN UTVIDELSE, ingen
|
||||||
|
innstramming: samme scratch-test bekreftet at en helt ubeslektet
|
||||||
|
FREMMED (innlogget, ikke deltaker) og en ANONYM leser fortsatt begge
|
||||||
|
avvises identisk som før (403 NOT_VISIBLE).
|
||||||
|
**Frontend:** `account-settings.tsx` fikk en ny "Personlig profil"-
|
||||||
|
seksjon (avatar-opplasting/fjerning, fornavn/etternavn/fødselsdato/
|
||||||
|
kjønn/HCP/hjemmeklubb-skjema). `dashboard.tsx` fikk en ny
|
||||||
|
`MyToursSection` («Mine runder», øverst, lenker til den offentlige
|
||||||
|
turnering-siden) + en mykere, sekundær utgave av "opprett organisasjon"-
|
||||||
|
tomtilstanden når brukeren allerede har spiller-data å vise.
|
||||||
|
**Bevisst UTENFOR omfang, klart flagget, IKKE en del av denne rundens
|
||||||
|
leveranse:** "Mine runder" lenker IKKE til lag-chat/scorekort ennå — de
|
||||||
|
krever fortsatt ekte organisasjonsmedlemskap (`get_authorized_org`), en
|
||||||
|
strengere, bredt brukt sperre som ikke ble endret denne runden (egen,
|
||||||
|
større og mer risikofylt endring, se ADR-031).
|
||||||
|
**Scratch-verifisert, 15 sjekker:** full profil-CRUD (alle felt satt,
|
||||||
|
delvis PATCH lar andre felt stå urørt, eksplisitt `null` sletter et
|
||||||
|
felt, tomt PATCH avvist, avatar lastet opp med ekte AVIF-URL og
|
||||||
|
slettet igjen, ugyldig filtype avvist), "Mine runder" for en EKTE ren
|
||||||
|
spiller uten org-medlemskap, OG den kritiske sikkerhetssjekken over.
|
||||||
|
Ekte typesjekket produksjonsbuild kjørt og bekreftet.
|
||||||
|
**Ikke rullet ut ennå** — venter på utrullingsbekreftelse
|
||||||
|
(`015_user_profile.sql` mot ekte `teecup_db`, deretter begge
|
||||||
|
containere).
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
1. **Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
|
1. **Personlig landingsside + profil er BYGGET (ADR-031), venter på
|
||||||
forespørsel:** offline-scoreregistrering er ALDRI browser-testet i
|
utrullingsbekreftelse** — se over (migrasjon 015 + begge containere).
|
||||||
|
Naturlig oppfølging etterpå: deltaker-tilgang (uten org-medlemskap) til
|
||||||
|
lag-chat/scorekort — bevisst utenfor omfang denne runden, se
|
||||||
|
FEATURE_BACKLOG.md.
|
||||||
|
2. **Punkt 2 fra samme 2026-07-20-runde (lavere prioritet enn #1):**
|
||||||
|
midlertidige spillere + automatisk etter-runde-e-post — forslag klart
|
||||||
|
i FEATURE_BACKLOG.md, tre åpne spørsmål, ingen kode skrevet.
|
||||||
|
3. Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
|
||||||
|
forespørsel: offline-scoreregistrering er ALDRI browser-testet i
|
||||||
praksis (kun kodegjennomgang + build-verifisering, se ADR-028). Bruker
|
praksis (kun kodegjennomgang + build-verifisering, se ADR-028). Bruker
|
||||||
bør selv åpne et scorekort, skru på Chrome DevTools sin Offline-bryter,
|
bør selv åpne et scorekort, skru på Chrome DevTools sin Offline-bryter,
|
||||||
registrere et par slag, skru nettet på igjen, og bekrefte at de faktisk
|
registrere et par slag, skru nettet på igjen, og bekrefte at de faktisk
|
||||||
synkes — først da er flyten reelt bevist.
|
synkes — først da er flyten reelt bevist.
|
||||||
2. Fire UI-/UX-hull notert 2026-07-19 (se over) — ingen fikset ennå.
|
4. Fire UI-/UX-hull notert 2026-07-19 (se over) — ingen fikset ennå.
|
||||||
Rewrite/medlemsside-bugen (#2) er den mest presserende siden siden er
|
Rewrite/medlemsside-bugen (#2) er den mest presserende siden siden er
|
||||||
helt utilgjengelig i dag.
|
helt utilgjengelig i dag.
|
||||||
3. **Alle fire nye hull (spillerliste, tee/kjønn-modell ADR-029,
|
5. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for
|
||||||
tallvelger, hcp-scope-bug) er FIKSET OG LIVE** (se over, 2026-07-19,
|
|
||||||
migrasjon 014 kjørt mot ekte data). En liten, urelatert 500-krasj
|
|
||||||
(stroke-innsending på bane uten hull) ble også funnet under
|
|
||||||
verifiseringen, ikke fikset — egen, liten sak.
|
|
||||||
4. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for
|
|
||||||
ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger
|
ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger
|
||||||
(bevisst utsatt i ADR-025).
|
(bevisst utsatt i ADR-025).
|
||||||
5. Flere turneringsformater utover Ryder Cup (Københavner/High-low-high/
|
6. Flere turneringsformater utover Ryder Cup (Københavner/High-low-high/
|
||||||
Robbins/Try all, notert 2026-07-19) — ingen ADR-runde startet ennå.
|
Robbins/Try all, notert 2026-07-19) — ingen ADR-runde startet ennå.
|
||||||
6. **Merk for neste økt:** `deploy/Caddyfile`-endringen (ny `/ws/*`-rute,
|
7. **Merk for neste økt:** `deploy/Caddyfile`-endringen (ny `/ws/*`-rute,
|
||||||
ADR-025) ligger uncommitted i det SEPARATE `/opt/teeoff`-repoet, ikke i
|
ADR-025) ligger uncommitted i det SEPARATE `/opt/teeoff`-repoet, ikke i
|
||||||
`teecup`-repoet — samme fallgruve som ADR-016-runden sin Caddy-endring,
|
`teecup`-repoet — samme fallgruve som ADR-016-runden sin Caddy-endring,
|
||||||
lett å glemme siden denne økten ellers kun har jobbet i `/opt/teecup`.
|
lett å glemme siden denne økten ellers kun har jobbet i `/opt/teecup`.
|
||||||
7. Ved fremtidige nye V0-skjermer/-design i V0 (fortsett i samme prosjekt),
|
8. Ved fremtidige nye V0-skjermer/-design i V0 (fortsett i samme prosjekt),
|
||||||
FORVENT en full re-eksport hver gang — diff mot live-treet i et
|
FORVENT en full re-eksport hver gang — diff mot live-treet i et
|
||||||
scratch-område før noe pakkes ut over eksisterende filer, og sjekk om
|
scratch-område før noe pakkes ut over eksisterende filer, og sjekk om
|
||||||
V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR
|
V0-skjermen bygger inn handlinger backend ikke støtter ennå FØR
|
||||||
|
|
|
||||||
|
|
@ -1011,6 +1011,103 @@ bekreftet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Personlig landingsside for ENHVER registrert bruker + personlig profil — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-20 (ADR-031)
|
||||||
|
|
||||||
|
Reist av brukeren 2026-07-20 som en "tenk igjennom og foreslå"-instruks,
|
||||||
|
deretter et "gjør det" med utvidet omfang (personlig profil-CRUD lagt til:
|
||||||
|
profilbilde, fornavn, etternavn, fødselsdato, kjønn, HCP, hjemmeklubb). Full
|
||||||
|
detalj i ARCHITECTURE_DECISIONS.md ADR-031 — kort her:
|
||||||
|
|
||||||
|
| Del | Status | Notat |
|
||||||
|
|---|---|---|
|
||||||
|
| Ett samlet dashboard (ikke to atskilte ruter) | ✅ | Ny "Mine runder"-seksjon øverst i `dashboard.tsx`, organisasjonsseksjonen uendret under, begge vises hvis begge finnes. |
|
||||||
|
| "Mine runder" — tverr-org spiller-oppslag | ✅ | Ny `player_organizations_for_user()`-bro (migrasjon 015, samme mønster som fire tidligere), `/auth/me` utvidet med `my_tournaments`. Kun rostrede lag i v1 (ikke rene påmeldinger uten roster). |
|
||||||
|
| Personlig profil (fornavn/etternavn/fødselsdato/kjønn/HCP/hjemmeklubb) | ✅ | Nye felt på `app_user` (migrasjon 015) — ETT sett per konto, BEVISST atskilt fra org-scopede `player`-rader (se ADR-031 Beslutning B for hvorfor). `PATCH /auth/profile`, ny seksjon i `/account`. |
|
||||||
|
| Profilbilde | ✅ | `POST`/`DELETE /auth/profile/avatar`, samme ekte multipart→AVIF-opplasting som turnering-hero-bilder (ADR-018). |
|
||||||
|
| **Sikkerhetsutvidelse funnet UNDER bygging:** deltaker-tilgang uansett synlighetsnivå | ✅ | `check_visibility()` ga tidligere kun deltaker-tilgang for `visibility='participants'` — IKKE for `'org'` (DEFAULT for enhver ny turnering), som ville stengt ute enhver spiller uten org-medlemskap fra sin EGEN turnering. Utvidet til å gjelde begge ikke-offentlige tier. Verifisert: deltaker FÅR nå tilgang, fremmed+anonym fortsatt avvist (ingen innstramming, ren utvidelse). |
|
||||||
|
|
||||||
|
**Bevisst UTENFOR omfang, kjent gjenstående begrensning (se ADR-031 for full
|
||||||
|
begrunnelse):** "Mine runder" lenker til den offentlige turnering-siden
|
||||||
|
(`/t/{id}`), IKKE til lagets private chat eller scorekortet — disse krever
|
||||||
|
fortsatt ekte organisasjonsmedlemskap (`get_authorized_org`), en strengere
|
||||||
|
sperre enn deltaker-status alene, brukt av dusinvis av endepunkter på tvers
|
||||||
|
av appen. Å utvide DEN sperren til også å godta "faktisk deltaker" er en
|
||||||
|
egen, større og mer risikofylt endring (påvirker autorisasjonsarkitekturen
|
||||||
|
bredt, ikke ett enkelt endepunkt) — naturlig neste steg, men bevisst ikke
|
||||||
|
gjort i denne runden. Notifikasjons-/aktivitetsfeed og HCP-historikk over
|
||||||
|
tid også bevisst utenfor omfang v1 (samme begrunnelse som opprinnelig
|
||||||
|
forslag).
|
||||||
|
|
||||||
|
**Scratch-verifisert, 15 sjekker** (profil-CRUD komplett, inkl. sletting av
|
||||||
|
enkeltfelt og avatar; en EKTE ren spiller uten org-medlemskap ser riktig
|
||||||
|
`my_tournaments`; den kritiske sikkerhetssjekken: samme spiller får nå se
|
||||||
|
sin `'org'`-synlige turnering, mens fremmed/anonym fortsatt avvises). Ekte
|
||||||
|
typesjekket produksjonsbuild kjørt og bekreftet.
|
||||||
|
|
||||||
|
**Ikke rullet ut ennå** — se CLAUDE.md-status.
|
||||||
|
|
||||||
|
### Naturlig neste steg (ikke bygget, notert for senere)
|
||||||
|
- **Deltaker-tilgang (uten org-medlemskap) til lag-chat og scorekort** — det
|
||||||
|
gjenstående hullet nevnt over. Krever en egen, forsiktig gjennomgang av
|
||||||
|
`get_authorized_org`-bruken (brukt bredt i hele appen) — ikke en rask fiks.
|
||||||
|
- Notifikasjons-/aktivitetsfeed på "Mine runder".
|
||||||
|
- HCP-historikk over tid (personlig profil sin `handicap_index` endres i
|
||||||
|
dag uten noen logg).
|
||||||
|
- "Mine runder" for RENE påmeldinger (`tournament_registration` uten
|
||||||
|
roster ennå) — v1 viser kun rostrede lag.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Midlertidige spillere + automatisk etter-runde-invitasjon — 📋 FORESLÅTT 2026-07-20, IKKE bygget ennå
|
||||||
|
|
||||||
|
Reist samme runde som punktet over, uttalt som punkt 2 (ikke like prioritert
|
||||||
|
som "Mine runder"-dashbordet, men skal likevel dokumenteres grundig nå).
|
||||||
|
|
||||||
|
**Viktig presisering, funnet ved kodegjennomgang FØR noe ble antatt:** det
|
||||||
|
meste av "midlertidig spiller"-behovet er allerede dekket av eksisterende
|
||||||
|
funksjonalitet, ikke et hull i seg selv — `POST /orgs/{id}/players` krever
|
||||||
|
ALDRI at spilleren har en konto (`player.user_id` er nullable, kobles først
|
||||||
|
automatisk når/hvis noen logger inn på matchende e-post, ADR-017
|
||||||
|
Beslutning B). En organisator kan altså allerede legge til "Ola Nordmann,
|
||||||
|
ola@example.com" uten at Ola noensinne har hørt om TeeCup. Det som
|
||||||
|
FAKTISK mangler er den PROAKTIVE oppfølgingen brukeren ber om: et
|
||||||
|
automatisk e-post-utsendelse-steg etter runden, med scorekort + invitasjon
|
||||||
|
til å logge inn og "ta eierskap" over spiller-profilen sin — dette finnes
|
||||||
|
ikke i noen form i dag (en spiller må selv, uoppfordret, logge inn for at
|
||||||
|
koblingen skal skje).
|
||||||
|
|
||||||
|
**Foreslått design, IKKE bygget:**
|
||||||
|
- **Utløses av en EKSPLISITT organisator-handling, ikke en automatisk
|
||||||
|
bakgrunnsjobb** ("Send scorekort og invitasjon til alle med e-post i
|
||||||
|
denne økten", en knapp på øktnivå når øktens matcher er avgjort).
|
||||||
|
Anbefalt fremfor helautomatisk utsendelse ved et gjettet
|
||||||
|
"runden er ferdig"-tidspunkt — unngår uventede e-poster fra en
|
||||||
|
feilaktig auto-deteksjon, og matcher prosjektets øvrige mønster (blind
|
||||||
|
draw krever eksplisitt lås, walkover er en eksplisitt handling — ingen
|
||||||
|
"magisk" auto-trigger noe annet sted i appen).
|
||||||
|
- **Ingen ny databasekolonne nødvendig for selve "midlertidig"-begrepet**
|
||||||
|
— enhver `player`-rad UTEN `user_id` ER allerede "midlertidig" i praksis.
|
||||||
|
Kun en NY, liten `sent_at`-lignende sporingskolonne kan trengs for å
|
||||||
|
unngå dobbel utsending ved gjentatt klikk (åpent spørsmål, se under).
|
||||||
|
- **E-posten gjenbruker eksisterende infrastruktur** (`app/email.py`,
|
||||||
|
samme SMTP-oppsett som magic-link/2FA) — innhold: spillerens
|
||||||
|
hull-for-hull-resultat for økten + en ekte innloggingslenke (vanlig
|
||||||
|
magic-link, ingen ny auth-mekanisme nødvendig siden `link_player_by_
|
||||||
|
email()` allerede kobler kontoen automatisk ved første innlogging).
|
||||||
|
|
||||||
|
**Åpne spørsmål, trengs FØR bygging:**
|
||||||
|
- Skal utsendingsknappen ligge på ØKT-nivå (send til alle i denne ene
|
||||||
|
runden) eller TURNERING-nivå (send til alle på tvers av alle økter, når
|
||||||
|
hele turneringen er ferdig)? Økt-nivå virker riktigst — en spiller kan
|
||||||
|
ha spilt kun én av flere økter.
|
||||||
|
- Skal systemet spore "allerede sendt til denne spilleren for denne økten"
|
||||||
|
for å hindre dobbel utsending ved et nytt klikk (sannsynligvis ja — én
|
||||||
|
liten ny tabell/kolonne)?
|
||||||
|
- Skal e-posten sendes på spillerens/organisasjonens foretrukne språk
|
||||||
|
(samme `locale`-mønster som magic-link-e-posten, ADR-015 Beslutning C)?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## UX / frontend (senere fase)
|
## UX / frontend (senere fase)
|
||||||
|
|
||||||
- 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp
|
- 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp
|
||||||
|
|
|
||||||
|
|
@ -38,14 +38,15 @@ import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import qrcode
|
import qrcode
|
||||||
from fastapi import APIRouter, Depends, Request, Response
|
from fastapi import APIRouter, Depends, Request, Response, UploadFile
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
from .. import storage
|
||||||
from ..auth import (
|
from ..auth import (
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
PendingUser,
|
PendingUser,
|
||||||
|
|
@ -555,6 +556,22 @@ class MyOrg(BaseModel):
|
||||||
role: str
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
class MyTournament(BaseModel):
|
||||||
|
"""Én rad = ett lag brukeren er ROSTRET på (ADR-031, 'Mine runder').
|
||||||
|
Bevisst utenfor omfang v1: turneringer der brukeren kun er PÅMELDT
|
||||||
|
(`tournament_registration`) men ikke ennå rostret på et lag."""
|
||||||
|
|
||||||
|
organization_id: str
|
||||||
|
organization_name: str
|
||||||
|
tournament_id: str
|
||||||
|
tournament_name: str
|
||||||
|
status: str
|
||||||
|
team_id: str
|
||||||
|
team_name: str
|
||||||
|
team_color: str | None
|
||||||
|
next_session_at: datetime | None
|
||||||
|
|
||||||
|
|
||||||
class Me(BaseModel):
|
class Me(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
email: str
|
email: str
|
||||||
|
|
@ -563,6 +580,17 @@ class Me(BaseModel):
|
||||||
has_password: bool
|
has_password: bool
|
||||||
two_factor_method: Literal["totp", "email"] | None
|
two_factor_method: Literal["totp", "email"] | None
|
||||||
organizations: list[MyOrg]
|
organizations: list[MyOrg]
|
||||||
|
# Personlig profil (ADR-031) -- ETT sett per konto, atskilt fra de
|
||||||
|
# org-scopede `player`-radene (se ADR-031 for hvorfor de ikke slås
|
||||||
|
# sammen til én ting).
|
||||||
|
first_name: str | None
|
||||||
|
last_name: str | None
|
||||||
|
birth_date: date | None
|
||||||
|
gender: str | None
|
||||||
|
handicap_index: float | None
|
||||||
|
home_club: str | None
|
||||||
|
avatar_url: str | None
|
||||||
|
my_tournaments: list[MyTournament]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=Me)
|
@router.get("/me", response_model=Me)
|
||||||
|
|
@ -577,11 +605,18 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
|
||||||
# (som setter riktig kontekst for akkurat den ene raden) -- N+1 spørringer,
|
# (som setter riktig kontekst for akkurat den ene raden) -- N+1 spørringer,
|
||||||
# men N er antall organisasjoner brukeren tilhører (typisk 1-3), og dette
|
# men N er antall organisasjoner brukeren tilhører (typisk 1-3), og dette
|
||||||
# er den eneste måten å gjøre det på uten å endre selve RLS-modellen.
|
# er den eneste måten å gjøre det på uten å endre selve RLS-modellen.
|
||||||
|
#
|
||||||
|
# ADR-031: samme N+1-mønster brukt for "Mine runder", nå over org-er
|
||||||
|
# funnet via player_organizations_for_user() (migrasjon 015) i stedet
|
||||||
|
# for organization_membership -- en bruker kan være SPILLER i en org
|
||||||
|
# helt uavhengig av om de er MEDLEM der.
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
user_row = await conn.fetchrow(
|
user_row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
|
SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
|
||||||
(password_hash IS NOT NULL) AS has_password, two_factor_method
|
(password_hash IS NOT NULL) AS has_password, two_factor_method,
|
||||||
|
first_name, last_name, birth_date, gender,
|
||||||
|
handicap_index::float AS handicap_index, home_club, avatar_key
|
||||||
FROM app_user WHERE id = $1
|
FROM app_user WHERE id = $1
|
||||||
""",
|
""",
|
||||||
user.user_id,
|
user.user_id,
|
||||||
|
|
@ -590,6 +625,10 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
|
||||||
"SELECT organization_id::text AS organization_id, role FROM organization_membership WHERE user_id = $1",
|
"SELECT organization_id::text AS organization_id, role FROM organization_membership WHERE user_id = $1",
|
||||||
user.user_id,
|
user.user_id,
|
||||||
)
|
)
|
||||||
|
player_org_rows = await conn.fetch(
|
||||||
|
"SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)",
|
||||||
|
user.user_id,
|
||||||
|
)
|
||||||
|
|
||||||
organizations = []
|
organizations = []
|
||||||
for m in membership_rows:
|
for m in membership_rows:
|
||||||
|
|
@ -599,6 +638,32 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
|
||||||
)
|
)
|
||||||
organizations.append(MyOrg(organization_id=m["organization_id"], name=name, role=m["role"]))
|
organizations.append(MyOrg(organization_id=m["organization_id"], name=name, role=m["role"]))
|
||||||
|
|
||||||
|
my_tournaments: list[MyTournament] = []
|
||||||
|
for p in player_org_rows:
|
||||||
|
org_id = p["organization_id"]
|
||||||
|
async with org_connection(org_id) as org_conn:
|
||||||
|
org_name = await org_conn.fetchval("SELECT name FROM organization WHERE id = $1", org_id)
|
||||||
|
rows = await org_conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT t.id::text AS tournament_id, t.name AS tournament_name,
|
||||||
|
t.status::text AS status,
|
||||||
|
tm.id::text AS team_id, tm.name AS team_name, tm.color AS team_color,
|
||||||
|
(SELECT min(s.scheduled_at) FROM session s
|
||||||
|
WHERE s.tournament_id = t.id AND s.scheduled_at > now()) AS next_session_at
|
||||||
|
FROM player pl
|
||||||
|
JOIN team_roster tr ON tr.player_id = pl.id
|
||||||
|
JOIN team tm ON tm.id = tr.team_id
|
||||||
|
JOIN tournament t ON t.id = tm.tournament_id
|
||||||
|
WHERE pl.user_id = $1
|
||||||
|
ORDER BY t.created_at DESC
|
||||||
|
""",
|
||||||
|
user.user_id,
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
my_tournaments.append(
|
||||||
|
MyTournament(organization_id=org_id, organization_name=org_name, **dict(r))
|
||||||
|
)
|
||||||
|
|
||||||
return Me(
|
return Me(
|
||||||
id=user_row["id"],
|
id=user_row["id"],
|
||||||
email=user_row["email"],
|
email=user_row["email"],
|
||||||
|
|
@ -607,4 +672,79 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
|
||||||
has_password=user_row["has_password"],
|
has_password=user_row["has_password"],
|
||||||
two_factor_method=user_row["two_factor_method"],
|
two_factor_method=user_row["two_factor_method"],
|
||||||
organizations=organizations,
|
organizations=organizations,
|
||||||
|
first_name=user_row["first_name"],
|
||||||
|
last_name=user_row["last_name"],
|
||||||
|
birth_date=user_row["birth_date"],
|
||||||
|
gender=user_row["gender"],
|
||||||
|
handicap_index=user_row["handicap_index"],
|
||||||
|
home_club=user_row["home_club"],
|
||||||
|
avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
|
||||||
|
my_tournaments=my_tournaments,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdate(BaseModel):
|
||||||
|
"""Personlig profil (ADR-031) -- PATCH-semantikk via exclude_unset, som
|
||||||
|
resten av appen. Et felt sendt eksplisitt som `null` NULLES (f.eks.
|
||||||
|
fjern fødselsdato), et UTELATT felt endres ikke -- vanlig
|
||||||
|
"sletting" av et enkeltfelt trenger derfor ingen egen DELETE-vei her,
|
||||||
|
kun avatar (binært innhold) har sin egen under."""
|
||||||
|
|
||||||
|
first_name: str | None = Field(default=None, max_length=100)
|
||||||
|
last_name: str | None = Field(default=None, max_length=100)
|
||||||
|
birth_date: date | None = None
|
||||||
|
gender: str | None = Field(default=None, pattern="^[mfx]$")
|
||||||
|
handicap_index: float | None = None
|
||||||
|
home_club: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/profile", response_model=Me)
|
||||||
|
async def update_profile(
|
||||||
|
body: ProfileUpdate,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> Me:
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
|
||||||
|
|
||||||
|
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
|
||||||
|
values = list(updates.values())
|
||||||
|
values.append(user.user_id)
|
||||||
|
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
f"UPDATE app_user SET {', '.join(set_clauses)} WHERE id = ${len(values)}",
|
||||||
|
*values,
|
||||||
|
)
|
||||||
|
return await me(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile/avatar", response_model=Me)
|
||||||
|
async def upload_avatar(
|
||||||
|
file: UploadFile,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> Me:
|
||||||
|
"""Samme ekte multipart->AVIF-mønster som tournament.hero_image_key
|
||||||
|
(ADR-018 MinIO-runden) -- se app/storage.py."""
|
||||||
|
if file.content_type not in storage.ALLOWED_INPUT_CONTENT_TYPES:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Ustøttet bildeformat.")
|
||||||
|
|
||||||
|
raw = await file.read(storage.MAX_UPLOAD_BYTES + 1)
|
||||||
|
if len(raw) > storage.MAX_UPLOAD_BYTES:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Bildet er for stort (maks 8 MB).")
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = await storage.upload_image("avatars", user.user_id, raw)
|
||||||
|
except storage.InvalidImageError:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Filen er ikke et gyldig bilde.")
|
||||||
|
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
await conn.execute("UPDATE app_user SET avatar_key = $1 WHERE id = $2", key, user.user_id)
|
||||||
|
return await me(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/profile/avatar", response_model=Me)
|
||||||
|
async def remove_avatar(user: CurrentUser = Depends(get_current_user)) -> Me:
|
||||||
|
async with plain_connection() as conn:
|
||||||
|
await conn.execute("UPDATE app_user SET avatar_key = NULL WHERE id = $1", user.user_id)
|
||||||
|
return await me(user)
|
||||||
|
|
|
||||||
|
|
@ -91,9 +91,15 @@ async def check_visibility(
|
||||||
)
|
)
|
||||||
if is_member:
|
if is_member:
|
||||||
return
|
return
|
||||||
if visibility == "participants" and await is_participant(
|
# ADR-031: en faktisk DELTAKER skal aldri stenges ute av øktens
|
||||||
conn, user.user_id, organization_id, tournament_id
|
# egen turnering, uansett hvilken av de to ikke-offentlige tierene
|
||||||
):
|
# som er valgt -- visibility styrer eksponering mot UTENFORSTÅENDE,
|
||||||
|
# ikke mot spillere som faktisk er med. Utvidet fra kun
|
||||||
|
# visibility == "participants" (opprinnelig ADR-018 Beslutning C)
|
||||||
|
# da "Mine runder"-dashbordet gjorde det tydelig at 'org' (som er
|
||||||
|
# DEFAULT for enhver ny turnering) ellers ville stengt ute enhver
|
||||||
|
# spiller uten organisasjonsmedlemskap fra sin egen turnering.
|
||||||
|
if await is_participant(conn, user.user_id, organization_id, tournament_id):
|
||||||
return
|
return
|
||||||
raise app_error(403, "NOT_VISIBLE", "Du har ikke tilgang til denne turneringen.")
|
raise app_error(403, "NOT_VISIBLE", "Du har ikke tilgang til denne turneringen.")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,29 @@
|
||||||
// (forced=false her -- kun tekst-copyen skiller de to).
|
// (forced=false her -- kun tekst-copyen skiller de to).
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, KeyRound, Lock, ShieldCheck, ShieldOff } from "lucide-react"
|
import { ArrowLeft, Camera, KeyRound, Lock, ShieldCheck, ShieldOff, User, X } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
|
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
|
||||||
|
|
||||||
|
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
|
||||||
|
// org-scopede `player`-radene organisatorer administrerer.
|
||||||
type Me = {
|
type Me = {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
display_name: string
|
display_name: string
|
||||||
has_password: boolean
|
has_password: boolean
|
||||||
two_factor_method: "totp" | "email" | null
|
two_factor_method: "totp" | "email" | null
|
||||||
|
first_name: string | null
|
||||||
|
last_name: string | null
|
||||||
|
birth_date: string | null
|
||||||
|
gender: "m" | "f" | "x" | null
|
||||||
|
handicap_index: number | null
|
||||||
|
home_club: string | null
|
||||||
|
avatar_url: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountSettings() {
|
export function AccountSettings() {
|
||||||
|
|
@ -90,6 +99,8 @@ export function AccountSettings() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
|
<ProfileSection me={me} onChanged={loadMe} />
|
||||||
|
|
||||||
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
|
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
|
||||||
|
|
||||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
||||||
|
|
@ -143,6 +154,225 @@ export function AccountSettings() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Personlig profil (ADR-031) ---------------------------------------------
|
||||||
|
|
||||||
|
function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
|
||||||
|
const [firstName, setFirstName] = useState(me.first_name ?? "")
|
||||||
|
const [lastName, setLastName] = useState(me.last_name ?? "")
|
||||||
|
const [birthDate, setBirthDate] = useState(me.birth_date ?? "")
|
||||||
|
const [gender, setGender] = useState(me.gender ?? "")
|
||||||
|
const [hcp, setHcp] = useState(me.handicap_index === null ? "" : String(me.handicap_index))
|
||||||
|
const [homeClub, setHomeClub] = useState(me.home_club ?? "")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [uploadingAvatar, setUploadingAvatar] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/auth/profile", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
first_name: firstName.trim() === "" ? null : firstName.trim(),
|
||||||
|
last_name: lastName.trim() === "" ? null : lastName.trim(),
|
||||||
|
birth_date: birthDate === "" ? null : birthDate,
|
||||||
|
gender: gender === "" ? null : gender,
|
||||||
|
handicap_index: hcp.trim() === "" ? null : Number(hcp.replace(",", ".")),
|
||||||
|
home_club: homeClub.trim() === "" ? null : homeClub.trim(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
throw new Error(body?.detail?.message ?? "Klarte ikke å lagre profilen.")
|
||||||
|
}
|
||||||
|
setSuccess(true)
|
||||||
|
onChanged()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAvatarSelected(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setUploadingAvatar(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", file)
|
||||||
|
const res = await fetch("/auth/profile/avatar", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Klarte ikke å laste opp profilbildet.")
|
||||||
|
onChanged()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Klarte ikke å laste opp profilbildet.")
|
||||||
|
} finally {
|
||||||
|
setUploadingAvatar(false)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveAvatar() {
|
||||||
|
setUploadingAvatar(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/auth/profile/avatar", { method: "DELETE", credentials: "include" })
|
||||||
|
if (res.ok) onChanged()
|
||||||
|
} finally {
|
||||||
|
setUploadingAvatar(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
||||||
|
<User aria-hidden="true" className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base font-bold text-foreground">Personlig profil</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex size-16 shrink-0 items-center justify-center overflow-hidden rounded-full bg-secondary">
|
||||||
|
{me.avatar_url ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={me.avatar_url} alt="" className="size-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<User aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAvatarSelected}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="h-9 rounded-xl font-semibold"
|
||||||
|
>
|
||||||
|
<Camera aria-hidden="true" className="size-4" />
|
||||||
|
{me.avatar_url ? "Bytt bilde" : "Last opp bilde"}
|
||||||
|
</Button>
|
||||||
|
{me.avatar_url && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveAvatar}
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
className="inline-flex w-fit items-center gap-1 text-xs font-semibold text-muted-foreground transition-colors hover:text-destructive"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" className="size-3" />
|
||||||
|
Fjern bilde
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="first-name" className="text-sm font-semibold">
|
||||||
|
Fornavn
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="first-name"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="last-name" className="text-sm font-semibold">
|
||||||
|
Etternavn
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="last-name"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="birth-date" className="text-sm font-semibold">
|
||||||
|
Fødselsdato
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="birth-date"
|
||||||
|
type="date"
|
||||||
|
value={birthDate}
|
||||||
|
onChange={(e) => setBirthDate(e.target.value)}
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="gender" className="text-sm font-semibold">
|
||||||
|
Kjønn
|
||||||
|
</Label>
|
||||||
|
<select
|
||||||
|
id="gender"
|
||||||
|
value={gender}
|
||||||
|
onChange={(e) => setGender(e.target.value)}
|
||||||
|
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Ikke satt</option>
|
||||||
|
<option value="f">Dame</option>
|
||||||
|
<option value="m">Herre</option>
|
||||||
|
<option value="x">Annet</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="hcp" className="text-sm font-semibold">
|
||||||
|
HCP
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="hcp"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={hcp}
|
||||||
|
onChange={(e) => setHcp(e.target.value)}
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="home-club" className="text-sm font-semibold">
|
||||||
|
Hjemmeklubb
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="home-club"
|
||||||
|
value={homeClub}
|
||||||
|
onChange={(e) => setHomeClub(e.target.value)}
|
||||||
|
className="h-11 rounded-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
{success && <p className="text-sm font-medium text-primary">Profilen er oppdatert.</p>}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={saving} className="h-11 w-fit rounded-xl font-semibold">
|
||||||
|
{saving ? "Lagrer …" : "Lagre profil"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) {
|
function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) {
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type React from "react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Building2, ChevronsUpDown, LogOut, Plus, Trophy, Users, UserCircle, X, Check } from "lucide-react"
|
import { Building2, Calendar, ChevronRight, ChevronsUpDown, LogOut, Plus, Trophy, Users, UserCircle, X, Check } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
@ -16,16 +16,32 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { Wordmark } from "@/components/wordmark"
|
import { Wordmark } from "@/components/wordmark"
|
||||||
import { TournamentCard, type Tournament } from "@/components/tournament-card"
|
import { TournamentCard, type Tournament } from "@/components/tournament-card"
|
||||||
import type { TournamentStatus } from "@/components/tournament-status-badge"
|
import { TournamentStatusBadge, type TournamentStatus } from "@/components/tournament-status-badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type MyOrg = { organization_id: string; name: string; role: string }
|
type MyOrg = { organization_id: string; name: string; role: string }
|
||||||
|
|
||||||
|
// ADR-031: "Mine runder" -- turneringer brukeren er SPILLER i (rostret på et
|
||||||
|
// lag), på tvers av organisasjoner, uavhengig av organisasjonsmedlemskap.
|
||||||
|
type MyTournament = {
|
||||||
|
organization_id: string
|
||||||
|
organization_name: string
|
||||||
|
tournament_id: string
|
||||||
|
tournament_name: string
|
||||||
|
status: TournamentStatus
|
||||||
|
team_id: string
|
||||||
|
team_name: string
|
||||||
|
team_color: string | null
|
||||||
|
next_session_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
type Me = {
|
type Me = {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
display_name: string
|
display_name: string
|
||||||
preferred_locale: string
|
preferred_locale: string
|
||||||
organizations: MyOrg[]
|
organizations: MyOrg[]
|
||||||
|
my_tournaments: MyTournament[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiTournament = {
|
type ApiTournament = {
|
||||||
|
|
@ -180,6 +196,7 @@ export function Dashboard() {
|
||||||
|
|
||||||
const activeOrg = me.organizations.find((o) => o.organization_id === activeOrgId) ?? null
|
const activeOrg = me.organizations.find((o) => o.organization_id === activeOrgId) ?? null
|
||||||
const hasOrg = me.organizations.length > 0 && activeOrg !== null
|
const hasOrg = me.organizations.length > 0 && activeOrg !== null
|
||||||
|
const hasMyTournaments = me.my_tournaments.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||||
|
|
@ -213,26 +230,97 @@ export function Dashboard() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasOrg && activeOrg ? (
|
<div className="flex flex-col gap-8">
|
||||||
<OrganizationView
|
{hasMyTournaments && <MyToursSection tournaments={me.my_tournaments} />}
|
||||||
org={activeOrg}
|
|
||||||
orgs={me.organizations}
|
{hasOrg && activeOrg ? (
|
||||||
tournaments={tournaments}
|
<OrganizationView
|
||||||
loadingTournaments={loadingTournaments}
|
org={activeOrg}
|
||||||
onSelectOrg={setActiveOrgId}
|
orgs={me.organizations}
|
||||||
onCreateTournament={handleCreateTournament}
|
tournaments={tournaments}
|
||||||
/>
|
loadingTournaments={loadingTournaments}
|
||||||
) : (
|
onSelectOrg={setActiveOrgId}
|
||||||
<CreateOrganizationState onCreate={handleCreateOrg} />
|
onCreateTournament={handleCreateTournament}
|
||||||
)}
|
/>
|
||||||
|
) : (
|
||||||
|
<CreateOrganizationState onCreate={handleCreateOrg} compact={hasMyTournaments} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- "Mine runder" (ADR-031) -----------------------------------------------
|
||||||
|
// Turneringer brukeren er SPILLER i, uavhengig av organisasjonsmedlemskap.
|
||||||
|
// Lenker til den offentlige turnering-siden (fungerer for enhver deltaker,
|
||||||
|
// se ADR-031-utvidelsen av check_visibility) -- IKKE til lag-chat/
|
||||||
|
// scorekort ennå, som fortsatt krever organisasjonsmedlemskap (kjent,
|
||||||
|
// notert begrensning, se FEATURE_BACKLOG.md).
|
||||||
|
|
||||||
|
function MyToursSection({ tournaments }: { tournaments: MyTournament[] }) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3" aria-label="Mine runder">
|
||||||
|
<h2 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Mine runder</h2>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{tournaments.map((t) => (
|
||||||
|
<Link
|
||||||
|
key={`${t.organization_id}-${t.tournament_id}`}
|
||||||
|
href={`/t/${t.tournament_id}`}
|
||||||
|
className="group flex items-center gap-4 rounded-2xl border border-border bg-card p-4 text-left shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-3 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: t.team_color ?? "#64748b" }}
|
||||||
|
/>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<h3 className="truncate text-base font-bold text-foreground">{t.tournament_name}</h3>
|
||||||
|
<TournamentStatusBadge status={t.status} />
|
||||||
|
</div>
|
||||||
|
<span className="truncate text-sm text-muted-foreground">
|
||||||
|
{t.organization_name} · {t.team_name}
|
||||||
|
</span>
|
||||||
|
{t.next_session_at && (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<Calendar aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
<span className="tabular-nums">{formatNextSession(t.next_session_at)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronRight
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-foreground"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNextSession(iso: string) {
|
||||||
|
const date = new Date(iso)
|
||||||
|
if (Number.isNaN(date.getTime())) return iso
|
||||||
|
return new Intl.DateTimeFormat("no-NO", {
|
||||||
|
weekday: "short",
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
// --- State A: no organization ---------------------------------------------
|
// --- State A: no organization ---------------------------------------------
|
||||||
|
|
||||||
function CreateOrganizationState({ onCreate }: { onCreate: (name: string) => void }) {
|
function CreateOrganizationState({
|
||||||
|
onCreate,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
onCreate: (name: string) => void
|
||||||
|
compact?: boolean
|
||||||
|
}) {
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const valid = name.trim().length >= 2
|
const valid = name.trim().length >= 2
|
||||||
|
|
||||||
|
|
@ -244,18 +332,26 @@ function CreateOrganizationState({ onCreate }: { onCreate: (name: string) => voi
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-md flex-col items-center gap-6 pt-6 text-center sm:pt-12">
|
<div
|
||||||
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
|
className={cn(
|
||||||
<Building2 aria-hidden="true" className="size-8 text-primary" />
|
"mx-auto flex max-w-md flex-col items-center text-center",
|
||||||
</div>
|
compact ? "gap-4" : "gap-6 pt-6 sm:pt-12",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!compact && (
|
||||||
|
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
|
||||||
|
<Building2 aria-hidden="true" className="size-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
|
<h1 className={cn("font-extrabold tracking-tight text-foreground text-balance", compact ? "text-lg" : "text-2xl")}>
|
||||||
Du har ingen organisasjon ennå
|
{compact ? "Opprett din egen organisasjon" : "Du har ingen organisasjon ennå"}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
En organisasjon er golfklubben eller bedriften som arrangerer turneringen. Opprett en for
|
{compact
|
||||||
å komme i gang.
|
? "Skal du selv arrangere turneringer? En organisasjon er golfklubben eller bedriften som står bak."
|
||||||
|
: "En organisasjon er golfklubben eller bedriften som arrangerer turneringen. Opprett en for å komme i gang."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue