diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a12281d..27919a5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -127,11 +127,40 @@ "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_prod.txt https://teecup.teeoff.no/auth/me)", "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_prod.txt /tmp/health_resp.json *)", "Bash(git -C /opt/teecup status --short)", - "Bash(git -C /opt/teeoff status --short)" + "Bash(git -C /opt/teeoff status --short)", + "Bash(echo \"exit=$?\")", + "Bash(python3 -m py_compile app/errors.py app/auth.py app/routers/auth.py app/email.py app/routers/tournaments.py app/routers/matches.py app/routers/scoring.py)", + "Bash(chmod 600 .env.scratch)", + "Bash(sed -i -e 's#/teecup_db$#/teecup_scratch#' -e 's#^TEECUP_DEV_LOG_MAGIC_LINKS=.*#TEECUP_DEV_LOG_MAGIC_LINKS=true#' .env.scratch)", + "Bash(grep -c '^TEECUP_DEV_LOG_MAGIC_LINKS=true$' .env.scratch)", + "Bash(rm -f /opt/teecup/.env.scratch /tmp/scratch_cookies.txt)", + "Bash(python3 -m py_compile app/config.py app/db.py)", + "Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/auth/me)", + "Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teeoff.no/)", + "Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/health)", + "Bash(ls -la /opt/teecup/.env.bak.* 2>&1 *)", + "Bash(curl -s -o /dev/null -w 'teecup.teeoff.no/health: %{http_code}\\\\n' https://teecup.teeoff.no/health)", + "Bash(curl -s -o /dev/null -w 'teeoff.no: %{http_code}\\\\n' https://teeoff.no/)", + "Bash(python3 -c \"import PIL; print\\(PIL.__version__\\)\")", + "Bash(dpkg -l)", + "Bash(sudo -n apt-get install -y libavif-bin)", + "Bash(apt-get install *)", + "Bash(ls -la /opt/ 2>&1 *)", + "Read(//opt/**)", + "Bash(find / -maxdepth 3 -iname \"*.zip\" -newer /opt/teecup/TeeOff-logo-Retina-1.avif)", + "Bash(find / -xdev -iname \"*.zip\" -mmin -60)", + "Bash(unzip -l tee-cup-login-screen.zip)", + "Bash(python3 -m zipfile -l tee-cup-login-screen.zip)", + "Bash(python3 -m zipfile -e tee-cup-login-screen.zip .)", + "Bash(python3)", + "Bash(docker --version)", + "Bash(docker run --rm -v /opt/teecup/frontend:/app -w /app node:22-slim bash -c ' *)", + "Bash(git -C /opt/teecup status --short frontend/)" ], "additionalDirectories": [ "/opt/teeoff/deploy", - "/tmp" + "/tmp", + "/opt/teecup" ] } } diff --git a/006_scheduling_and_locale.sql b/006_scheduling_and_locale.sql new file mode 100644 index 0000000..664533b --- /dev/null +++ b/006_scheduling_and_locale.sql @@ -0,0 +1,43 @@ +-- ===================================================================== +-- TeeCup — migrasjon 006 +-- Dato/klokkeslett for turnering/økt/match + locale-forberedelse (i18n) +-- ===================================================================== +-- Kjøres etter 001-005. Alt additivt (nullable eller default) — trygt mot +-- en database som allerede er live. +-- ===================================================================== + +\set ON_ERROR_STOP on + +-- --------------------------------------------------------------------- +-- 1. Turnering: sluttdato (start_date fantes allerede fra 001) +-- --------------------------------------------------------------------- +ALTER TABLE tournament ADD COLUMN end_date date; +ALTER TABLE tournament ADD CONSTRAINT tournament_date_order + CHECK (end_date IS NULL OR start_date IS NULL OR end_date >= start_date); + +-- --------------------------------------------------------------------- +-- 2. Økt: starttid + intervall mellom flights, pluss startshull. +-- match.tee_time utledes av disse (scheduled_at + (sequence-1) * +-- tee_interval_minutes) i app-laget, IKKE lagret per match -- se +-- match.tee_time_override under for unntakstilfeller. +-- --------------------------------------------------------------------- +ALTER TABLE session ADD COLUMN scheduled_at timestamptz; +ALTER TABLE session ADD COLUMN tee_interval_minutes smallint + CHECK (tee_interval_minutes IS NULL OR tee_interval_minutes > 0); +ALTER TABLE session ADD COLUMN start_hole smallint NOT NULL DEFAULT 1 + CHECK (start_hole BETWEEN 1 AND 18); + +-- --------------------------------------------------------------------- +-- 3. Match: valgfri overstyring av utledet tee_time (forsinkelser e.l.) +-- --------------------------------------------------------------------- +ALTER TABLE match ADD COLUMN tee_time_override timestamptz; + +-- --------------------------------------------------------------------- +-- 4. i18n-forberedelse: kun to locales foreløpig -- CHECK gjør det +-- eksplisitt at flere krever en ny migrasjon, ikke en stille utvidelse. +-- --------------------------------------------------------------------- +ALTER TABLE app_user ADD COLUMN preferred_locale text NOT NULL DEFAULT 'nb' + CHECK (preferred_locale IN ('nb', 'en')); + +ALTER TABLE magic_link_token ADD COLUMN locale text NOT NULL DEFAULT 'nb' + CHECK (locale IN ('nb', 'en')); diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index e881558..c30bb65 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -293,6 +293,76 @@ Playing Handicap i stedet for å kalle `match_play_strokes`. --- +## ADR-015 — Utledet tee-time og maskinlesbar feilkode-kontrakt + +**Beslutning A — klokkeslett er utledet, ikke lagret per match:** `session` +får `scheduled_at` (starttid for FØRSTE match) + `tee_interval_minutes` +(minutter mellom hver "flight"). En matchs `tee_time` er +`scheduled_at + (sequence-1) * tee_interval_minutes`, beregnet i Python ved +lesing (`app/routers/matches.py`), ALDRI skrevet til `match`-raden. Unntak: +`match.tee_time_override` (nullable `timestamptz`) for enkeltmatcher som må +justeres uavhengig (forsinkelser) — vinner alltid over den utledede verdien +når satt. `session.start_hole` er eksplisitt lagret (ikke utledet av +`hole_config`). + +**Begrunnelse:** Brukeren beskrev selv mønsteret rett fra domenet: "man sier +at førstematchen starter på hull X klokka Y, og det er Z minutter mellom hver +flight" — det er slik tee-tider faktisk fungerer i golf, og å lagre ett +tidspunkt per match ville vært duplisert, avledet data som kunne komme ut av +synk med intervallet. Et lite unntak (override) dekker det ene tilfellet der +den avledede regelen ikke holder. + +**Konsekvens:** Alle tre nye tidsfelter er nullable/additive (migrasjon +`006_scheduling_and_locale.sql`) — en økt uten planlagt klokkeslett gir +`tee_time: null` i API-et, ikke en feil. Det finnes ennå intet +PATCH-endepunkt for `tee_time_override` (satt direkte i databasen ved +verifisering); bygges når frontend faktisk trenger å justere enkeltmatcher. + +**Beslutning B — én uniform feilrespons på tvers av HELE API-et:** +`{"detail": {"code": "...", "message": "..."}}` på ALLE stedene som kaster +`HTTPException` (39 steder ved innføring), via en liten factory +`app_error(status_code, code, message)` i `app/errors.py` — ingen egen +exception-klasse, ingen global exception handler. Kodene er en liten, +gjenbrukt taksonomi (~15 koder, IKKE én unik kode per kastested) — +f.eks. `NOT_FOUND`, `DUPLICATE`, `LIMIT_REACHED`, `NOT_ROSTERED_ON_TEAM`, +`NOT_AUTHENTICATED`. Pydantic sine egne 422-valideringsfeil er bevisst +UTENFOR denne kontrakten og beholder FastAPI sin standard `{"detail": [...]}`. + +**Begrunnelse:** Hardkodet norsk prosa i `detail` (slik det var før denne +runden) kan ikke brukes til noe annet enn "vis strengen" av en frontend — +den kan ikke skille en 409 pga. dupliserte rader fra en 409 pga. et +forretningstak, og kan ikke oversettes (se i18n under). En liten, gjenbrukt +kode-taksonomi lar frontend bygge stabil logikk (f.eks. "vis en spesifikk +inline-feil for `LIMIT_REACHED`, en generisk toast for alt annet") uten å +måtte parse norsk tekst. Skulle vært gjort FØR frontend startet — dyrt å +ettermontere når klientkode allerede har begynt å parse tekststrenger. + +**Konsekvens:** Enhver ny `HTTPException` i fremtidig API-kode SKAL bruke +`app_error()`, aldri en rå `HTTPException(status_code, detail="...")` — se +`app/errors.py` sin docstring. Nye forretningsbetydninger som ikke passer +noen eksisterende kode får en ny kode i taksonomien, ikke gjenbruk av en +semantisk feil kode. + +**Beslutning C — locale er klient-oppgitt, ikke server-gjettet:** +`app_user.preferred_locale` og `magic_link_token.locale` (kun `nb`/`en` +foreløpig, håndhevet med `CHECK`) settes fra en `locale`-verdi klienten +sender eksplisitt ved `POST /auth/request-link` — ALDRI gjettet server-side +(f.eks. fra `Accept-Language`). En NY bruker får `preferred_locale` satt fra +forespørselens locale ved førstegangsopprettelse; en EKSISTERENDE bruker som +ber om en ny lenke på et annet språk får IKKE sin lagrede preferanse +overskrevet — kun selve e-posten sendes på forespørselens språk. Ingen +endepunkt for å ENDRE `preferred_locale` på en eksisterende bruker ennå +(egen, senere sak — profilinnstillinger). + +**Begrunnelse:** Frontend vet sitt eget gjeldende visningsspråk (brukerens +valg i UI-et) — det er en bedre kilde enn å gjette fra headere eller IP. +At en påfølgende innlogging på et annet språk (f.eks. en gjest som låner en +enhet) IKKE skal endre den lagrede preferansen er bevisst: en +innloggingshandling bør ikke ha den overraskende bivirkningen å endre +brukerens varige profilinnstilling. + +--- + ## Åpne spørsmål (ikke besluttet ennå) Disse må avklares før eller under de relevante fasene: diff --git a/CLAUDE.md b/CLAUDE.md index 0866387..f3fac1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber. ## Autoritative kilder (les før du gjør noe) -- `ARCHITECTURE_DECISIONS.md` — hva som er bestemt og hvorfor (ADR-001…014). Fasit. +- `ARCHITECTURE_DECISIONS.md` — hva som er bestemt og hvorfor (ADR-001…015). Fasit. - `FEATURE_BACKLOG.md` — hva som gjenstår, hva som er utsatt, hva som mangler. - Endres en beslutning: legg til en ny ADR, ikke slett historikk. Hold begge filene oppdatert når noe avgjøres. @@ -186,10 +186,75 @@ Ferdig og verifisert: som skal tas i bruk; (c) et `#`-tegn i et upassordet `.env`-passord kuttes som en kommentar av Compose sin parser — anførselstegn (fortrinnsvis enkle) løser dette. +- **Dato/klokkeslett + feilkode-kontrakt + i18n-forberedelse (2026-07-17, + ADR-015):** reist av brukeren rett før frontend-arbeidet. Ny migrasjon + `006_scheduling_and_locale.sql` (alt additivt): `tournament.end_date`, + `session.scheduled_at`/`tee_interval_minutes`/`start_hole`, + `match.tee_time_override`, `app_user.preferred_locale`, + `magic_link_token.locale`. `match.tee_time` er UTLEDET i Python + (`scheduled_at + (sequence-1)*tee_interval_minutes`, override vinner hvis + satt) — aldri lagret per match. Full retrofit av ALLE 39 daværende + `HTTPException(..., detail="norsk streng")`-steder på tvers av 6 filer til + en delt `app_error(status_code, code, message)`-factory + (`app/errors.py`) — responsformen er nå konsekvent + `{"detail":{"code":...,"message":...}}` i hele API-et, verifisert med et + siste `grep -rn 'detail="' app/` som ga NULL treff. i18n: `locale` + (`nb`/`en`) sendes av klienten ved `request-link`, styrer e-postmalen + (ekte engelsk mal lagt inn i `app/email.py`, ikke bare rørlegging) OG + settes som en HELT NY brukers `preferred_locale` — en eksisterende bruker + som logger inn på et annet språk får IKKE sin lagrede preferanse + overskrevet. + **Fant og fikset underveis:** `tournament.start_date` har ligget i + skjemaet siden migrasjon 001, men var ALDRI koblet til + `TournamentCreate`/`Tournament`-modellene — funnet som en naturlig + bivirkning av å legge til `end_date`. + **Verifisert grundig mot fersk `teecup_scratch`** (001→006, + `test_isolation.sql` fortsatt 12/12): feilkode-form bekreftet på tvers av + 5 filer (`NOT_FOUND`/`LIMIT_REACHED` tournaments.py, `DUPLICATE` roster, + `NOT_AUTHENTICATED` uten cookie, `NOT_ROSTERED_ON_TEAM` matches.py), + tee_time-beregning bekreftet (10 min intervall → 08:00/08:10/08:20, + override vinner, `scheduled_at=null` gir `tee_time:null` ikke feil), full + i18n-runde bekreftet (ny bruker `locale:"en"` → `preferred_locale:"en"`; + påfølgende `request-link` for samme bruker med `locale:"nb"` skiftet + e-postmalen men IKKE den lagrede preferansen, bekreftet via `/auth/me`). + **Kjørt mot ekte `teecup_db` 2026-07-17** (bruker bekreftet eksplisitt i + samme økt): migrasjonen kjørte rent, `test_isolation.sql` fortsatt 12/12 + (ruller alltid tilbake, ingen domenerader berørt). + **Mindre driftslærdom, funnet OG rettet samme runde:** et `.env`-filter + (`grep -v -i 'pass|secret|key'`) jeg brukte for å lese ikke-sensitive + nøkler fanget ikke opp `TEECUP_DATABASE_URL`, som bar `teecup_app`- + passordet innebygd i selve URL-en (i tillegg til at det SAMME passordet + allerede lå rent i `TEECUP_APP_PASSWORD` — duplisert, ikke bare skjult) — + passordet ble dermed synlig i et verktøyresultat. Flagget til brukeren + umiddelbart (samme mønster som SMTP-passord-hendelsen over). +- **`.env`/tilkobling ryddet opp (2026-07-17), samme runde:** roten til + hendelsen over var at `TEECUP_DATABASE_URL` var én sammensatt DSN-streng + med passordet URL-kodet inni — usynlig for navnebaserte secret-filtre. + Erstattet med fem separate, rent navngitte felt: + `TEECUP_DB_HOST`/`TEECUP_DB_PORT`/`TEECUP_DB_NAME`/`TEECUP_DB_USER`/ + `TEECUP_DB_PASS` (sistnevnte omdøpt fra `TEECUP_APP_PASSWORD`, kun + nøkkelnavnet — verdien aldri lest eller skrevet av meg). `app/config.py` + og `app/db.py` bygger nå `asyncpg`-poolen fra disse fem separate feltene + (`host=`/`port=`/`user=`/`password=`/`database=`) i stedet for én DSN — + fjerner også URL-prosentkoding-problemet fra SMTP-passord-hendelsen sin + klasse av feil. `docker-compose.yml` sin `environment:`-liste oppdatert + tilsvarende. **Krevde et fullt image-rebuild, ikke bare + `--force-recreate`:** `Dockerfile` sin `COPY app/ app/` bakes inn i + imaget ved build-tid (ingen bind-mount i prod, i motsetning til + scratch-verifiseringens engangscontainere) — en ren `--force-recreate` + gjenbrukte det GAMLE imaget og krasjet umiddelbart på den nå fjernede + `TEECUP_DATABASE_URL`. Rettet med `docker compose up -d --build + --force-recreate`. **Verifisert ende-til-ende mot den live stacken:** + containeren boot-et rent (`Application startup complete` i loggen, som + krever en vellykket `init_pool()` — asyncpg ville kastet og forhindret + akkurat den logglinjen ved feil tilkoblingsparametre), `/auth/me` over + ekte https ga et rent `401 NOT_AUTHENTICATED` (ikke 500/502), `teeoff.no` + upåvirket (`200` gjennom hele omstarten, kun `teecup_api` restartet — ikke + delt Caddy-instans, ikke samme risikoklasse som Caddy-hendelsen fra + containeriseringsrunden). Neste steg: -1. Frontend (PWA, offline-first) og kommunikasjon (migrasjon 006, siden - 004/005 nå er tatt av auth og RLS-fiksen). Frontend er fortsatt IKKE +1. Frontend (PWA, offline-first) og kommunikasjon. Frontend er fortsatt IKKE startet (⬜ i utviklingsplanen i ARCHITECTURE_DECISIONS.md) — helt frem til nå har API-et vært nåbart, men uten noe grensesnitt en sluttbruker kan bruke. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 4edbbd0..afb0495 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -7,7 +7,7 @@ > Status-koder: ✅ ferdig · 🔨 pågår · 📋 planlagt/fanget · ❓ trenger beslutning > · 🔀 endret fra opprinnelig råd · 💤 utsatt (bevisst) > -> Sist oppdatert: 2026-07-16 +> Sist oppdatert: 2026-07-17 --- @@ -32,6 +32,71 @@ | Organisasjon-bootstrap (opprette ny org via API) | ✅ | `POST /orgs`, `app/routers/organizations.py`. Se detaljer under. | | Ekte SMTP-utsending av magic-link | ✅ | `app/email.py`. Se detaljer under. | | Containerisert, LIVE på `teecup.teeoff.no` | ✅ | `Dockerfile` + `docker-compose.yml`. Se egen seksjon under — to reelle driftshendelser funnet og rettet. | +| Dato/klokkeslett på turnering/økt/match | ✅ | ADR-015. `session.scheduled_at` + `tee_interval_minutes` → utledet `match.tee_time`. Se egen seksjon under. | +| Maskinlesbar feilkode-kontrakt (`{"code",...}`) | ✅ | ADR-015. Full retrofit, alle 39 tidligere steder. Se egen seksjon under. | +| i18n-forberedelse (nb/en) | ✅ | ADR-015. `preferred_locale` + ekte engelsk e-postmal. Se egen seksjon under. | + +--- + +### Dato/klokkeslett + feilkode-kontrakt + i18n-forberedelse — ✅ BYGGET OG VERIFISERT 2026-07-17 +- Reist av brukeren rett før frontend-arbeidet: kan turnering/økt/match ha + dato/klokkeslett, og må appen forberedes for flerspråklighet? Begge er + API-kontraktspørsmål som er langt billigere å løse FØR frontend bygges enn + å ettermontere — se ADR-015 for alle tre beslutningene i sin helhet. +- **Migrasjon `006_scheduling_and_locale.sql`:** alt additivt (nullable/ + default) — `tournament.end_date` (+ `CHECK` mot `start_date`), + `session.scheduled_at`/`tee_interval_minutes`/`start_hole`, + `match.tee_time_override`, `app_user.preferred_locale`, + `magic_link_token.locale` (begge sistnevnte `CHECK IN ('nb','en')`). Kjørt + permanent mot den ekte `teecup_db` (se under). +- **Feilkoder:** ny `app_error(status_code, code, message)`-factory i + `app/errors.py`, alle 39 tidligere norsk-hardkodede `HTTPException(..., + detail="...")`-steder på tvers av 6 filer (`errors.py`, `auth.py`, + `routers/auth.py`, `routers/tournaments.py`, `routers/matches.py`, + `routers/scoring.py`) retrofittet til en delt ~15-kode-taksonomi. Endelig + `grep -rn 'detail="' app/` ga NULL treff. +- **Dato/tid i API-et:** `tournament.start_date` (fantes i skjemaet siden + 001, men var ALDRI koblet til API-modellene før nå — funnet og fikset som + en naturlig bivirkning av å legge til `end_date`) + `end_date` eksponert; + `session` sine tre nye felt eksponert i `SessionCreate`/`SessionOut`; + `match.tee_time` utledet i Python i både `create_match` og `list_matches` + (ingen ekstra spørring — økten hentes allerede for andre formål der). +- **i18n:** `MagicLinkRequest.locale` (`Literal["nb","en"]`, default `nb`) + sendes av klienten, følger med i token-raden, brukes til å velge + e-postmal OG (kun ved førstegangsopprettelse) `app_user.preferred_locale`. + Ekte engelsk e-postmal lagt inn i `app/email.py` (ikke bare rørlegging) — + konkret bevis på at flerspråklighet fungerer ende-til-ende, ikke bare at + et felt eksisterer. +- **Verifisert grundig mot en fersk `teecup_scratch`** (migrasjoner + 001→006, `test_isolation.sql` 12/12 uendret, engangs-API-container): + feilkode-form bekreftet på tvers av 5 filer (`NOT_FOUND`/`LIMIT_REACHED` + fra tournaments.py, `DUPLICATE` fra roster, `NOT_AUTHENTICATED` uten + cookie, `NOT_ROSTERED_ON_TEAM` fra matches.py sin `add_participant`); + `tee_time` bekreftet å øke riktig per `sequence` (10 min intervall → + 08:00/08:10/08:20), `tee_time_override` bekreftet å vinne over utledet + verdi, økt uten `scheduled_at` bekreftet å gi `tee_time: null` (ikke + feil); i18n bekreftet full runde — ny bruker med `locale:"en"` fikk + `preferred_locale:"en"`, en ETTERFØLGENDE `request-link` for samme bruker + med `locale:"nb"` skiftet e-postmalen men IKKE den lagrede preferansen + (bekreftet uendret via `/auth/me`). +- **Migrasjon 006 kjørt mot ekte `teecup_db` 2026-07-17**, bruker bekreftet + eksplisitt i samme økt — kjørte rent, `test_isolation.sql` fortsatt 12/12. +- **Mindre driftslærdom, funnet OG rettet samme runde:** et innledende + `grep -v -i 'pass|secret|key'`-filter jeg brukte for å lese ikke-sensitive + `.env`-nøkler fanget IKKE opp `TEECUP_DATABASE_URL`, som bar + `teecup_app`-passordet innebygd i selve URL-en (i tillegg duplisert av det + samme passordet som allerede lå rent i `TEECUP_APP_PASSWORD`) — passordet + endte dermed synlig i et verktøyresultat. Flagget til brukeren umiddelbart + (samme mønster som SMTP-passord-hendelsen under containeriseringsrunden). + **Rettet:** `.env` bygget om til fem separate, rent navngitte felt + (`TEECUP_DB_HOST/PORT/NAME/USER/PASS`), `app/config.py`+`app/db.py` bygger + nå `asyncpg`-poolen fra disse i stedet for én DSN-streng, + `docker-compose.yml` oppdatert tilsvarende. Se CLAUDE.md-status for full + driftsdetalj, inkl. at dette krevde et fullt + `docker compose up -d --build --force-recreate` (ikke bare + `--force-recreate` — imaget bygger inn `app/` ved build-tid). Verifisert + ende-til-ende mot den live stacken (`Application startup complete`, + `/auth/me` ga rent `401` over ekte https, `teeoff.no` upåvirket). --- diff --git a/TeeOff-logo-Retina-1.avif b/TeeOff-logo-Retina-1.avif new file mode 100644 index 0000000..d13ed0e Binary files /dev/null and b/TeeOff-logo-Retina-1.avif differ diff --git a/app/auth.py b/app/auth.py index f7230e5..f64733d 100644 --- a/app/auth.py +++ b/app/auth.py @@ -20,10 +20,11 @@ import time from dataclasses import dataclass import jwt -from fastapi import Depends, HTTPException, Request, status +from fastapi import Depends, Request from .config import settings from .db import plain_connection +from .errors import app_error SESSION_COOKIE_NAME = "teecup_session" _JWT_ALGORITHM = "HS256" # Eksplisitt både ved signering og dekoding -- blokkerer @@ -62,20 +63,20 @@ async def get_current_user(request: Request) -> CurrentUser: """ token = request.cookies.get(SESSION_COOKIE_NAME) if not token: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ikke innlogget.") + raise app_error(401, "NOT_AUTHENTICATED", "Ikke innlogget.") try: claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM]) except jwt.PyJWTError: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ugyldig eller utløpt sesjon.") + raise app_error(401, "NOT_AUTHENTICATED", "Ugyldig eller utløpt sesjon.") user_id = claims.get("sub") if not user_id: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ugyldig sesjon.") + raise app_error(401, "NOT_AUTHENTICATED", "Ugyldig sesjon.") async with plain_connection() as conn: exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM app_user WHERE id = $1)", user_id) if not exists: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Brukeren finnes ikke lenger.") + raise app_error(401, "NOT_AUTHENTICATED", "Brukeren finnes ikke lenger.") return CurrentUser(user_id=user_id) @@ -101,8 +102,5 @@ async def get_authorized_org( organization_id, ) if not is_member: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Brukeren er ikke medlem av denne organisasjonen.", - ) + raise app_error(403, "NOT_ORG_MEMBER", "Brukeren er ikke medlem av denne organisasjonen.") return organization_id diff --git a/app/config.py b/app/config.py index 0aac72e..1496beb 100644 --- a/app/config.py +++ b/app/config.py @@ -21,8 +21,15 @@ def _required(name: str) -> str: class Settings: # Peker på teecup_db, og bruker RUNTIME-rollen teecup_app (ikke teeoff_admin). - # Format: postgresql://teecup_app:@:/teecup_db - DATABASE_URL: str = _required("TEECUP_DATABASE_URL") + # Fem separate felt (ikke én sammensatt DSN-streng) -- en URL som bærer + # passordet innebygd unngår ikke hemmeligheten, den gjemmer den bare et + # sted secret-filtre som leter etter "pass|secret|key" i variabelNAVNET + # ikke ser etter (se CLAUDE.md-status for hendelsen dette rettet). + DB_HOST: str = _required("TEECUP_DB_HOST") + DB_PORT: int = int(_required("TEECUP_DB_PORT")) + DB_NAME: str = _required("TEECUP_DB_NAME") + DB_USER: str = _required("TEECUP_DB_USER") + DB_PASS: str = _required("TEECUP_DB_PASS") # Egen sesjons-secret, uavhengig av teeoff. SESSION_SECRET: str = _required("TEECUP_SESSION_SECRET") diff --git a/app/db.py b/app/db.py index a06b05f..72dc1ba 100644 --- a/app/db.py +++ b/app/db.py @@ -28,7 +28,11 @@ _pool: asyncpg.Pool | None = None async def init_pool() -> None: global _pool _pool = await asyncpg.create_pool( - dsn=settings.DATABASE_URL, + host=settings.DB_HOST, + port=settings.DB_PORT, + user=settings.DB_USER, + password=settings.DB_PASS, + database=settings.DB_NAME, min_size=settings.DB_POOL_MIN, max_size=settings.DB_POOL_MAX, ) diff --git a/app/email.py b/app/email.py index 58add9d..78b219b 100644 --- a/app/email.py +++ b/app/email.py @@ -37,18 +37,38 @@ def _send_sync(to_email: str, subject: str, body: str) -> None: smtp.send_message(msg) -async def send_magic_link_email(to_email: str, raw_token: str) -> None: - """Sender selve magic-link-tokenet som klartekst. +_TEMPLATES = { + "nb": { + "subject": "Logg inn på TeeCup", + "body": ( + "Hei,\n\n" + "Bruk denne koden for å logge inn på TeeCup innen {minutes} minutter:\n\n" + "{token}\n\n" + "Ba du ikke om dette, kan du se bort fra e-posten.\n" + ), + }, + "en": { + "subject": "Log in to TeeCup", + "body": ( + "Hi,\n\n" + "Use this code to log in to TeeCup within {minutes} minutes:\n\n" + "{token}\n\n" + "If you didn't request this, you can ignore this email.\n" + ), + }, +} + + +async def send_magic_link_email(to_email: str, raw_token: str, locale: str = "nb") -> None: + """Sender selve magic-link-tokenet som klartekst, på angitt språk. MERK: ingen klikkbar lenke ennå -- TeeCup har ikke noe frontend å peke på (se plan). Når frontend bygges, oppdater malen til en ekte URL. + `locale` valideres allerede av Pydantic (Literal["nb","en"]) på vei inn i + routeren, men faller trygt tilbake til "nb" her også hvis den skulle + komme fra et annet kall uten den garantien. """ - subject = "Logg inn på TeeCup" - body = ( - "Hei,\n\n" - f"Bruk denne koden for å logge inn på TeeCup innen " - f"{settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter:\n\n" - f"{raw_token}\n\n" - "Ba du ikke om dette, kan du se bort fra e-posten.\n" - ) + template = _TEMPLATES.get(locale, _TEMPLATES["nb"]) + subject = template["subject"] + body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, token=raw_token) await to_thread(_send_sync, to_email, subject, body) diff --git a/app/errors.py b/app/errors.py index 688ceb6..905cc3f 100644 --- a/app/errors.py +++ b/app/errors.py @@ -1,15 +1,32 @@ """ -Felles feiloversettelse: Postgres-constraint-brudd -> rene HTTP-feil. +Felles feilhåndtering: en maskinlesbar `code` på ALLE HTTP-feil, pluss +oversettelse av Postgres-constraint-brudd -> rene HTTP-feil. -Mønsteret (unik nøkkel, fremmednøkkel, CHECK) gjentar seg på tvers av routerne -(lag-navn, roster, sesjons-rekkefølge, lås). Meldingene er generiske og lekker -aldri rå Postgres-tekst (constraint-/tabellnavn) til klienten. +Responsform på tvers av HELE API-et: {"detail": {"code": "...", "message": "..."}}. +Ingen egen exception-klasse, ingen global handler -- FastAPI støtter allerede +at `detail` er et JSON-serialiserbart objekt, ikke bare en streng, så +`app_error()` er bare en liten factory brukt konsekvent på alle ~39 steder +som kaster HTTPException i dette API-et. + +Kodene er gjenbrukt der samme forretningsbetydning gjentar seg (f.eks. +NOT_FOUND på tvers av mange ressurstyper) -- IKKE én unik kode per kastested, +det ville vært for finkornet til at en frontend kan bygge stabil logikk på +det. Se plan-loggen for hele taksonomien. + +Postgres-constraint-mønsteret (unik nøkkel, fremmednøkkel, CHECK) gjentar seg +på tvers av routerne (lag-navn, roster, sesjons-rekkefølge, lås). Meldingene +er generiske og lekker aldri rå Postgres-tekst (constraint-/tabellnavn) til +klienten. """ from contextlib import asynccontextmanager import asyncpg -from fastapi import HTTPException, status +from fastapi import HTTPException + + +def app_error(status_code: int, code: str, message: str) -> HTTPException: + return HTTPException(status_code=status_code, detail={"code": code, "message": message}) # async (ikke bare @contextmanager) slik at den kan stå i samme @@ -22,17 +39,10 @@ async def translate_db_errors(): try: yield except asyncpg.UniqueViolationError as e: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="Raden finnes allerede (dupliserer noe som skal være unikt).", + raise app_error( + 409, "DUPLICATE", "Raden finnes allerede (dupliserer noe som skal være unikt)." ) from e except asyncpg.ForeignKeyViolationError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Refererer til noe som ikke finnes.", - ) from e + raise app_error(400, "INVALID_REFERENCE", "Refererer til noe som ikke finnes.") from e except asyncpg.CheckViolationError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Verdien bryter en regel for feltet.", - ) from e + raise app_error(400, "VALIDATION_FAILED", "Verdien bryter en regel for feltet.") from e diff --git a/app/routers/auth.py b/app/routers/auth.py index aae8c88..fa2231c 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -17,20 +17,28 @@ Sikkerhetsmønster (se plan/ADR-009): eller ingen av delene konfigurert) logges server-side og lekker ALDRI til klienten som en annen respons -- det ville brutt enumereringsvernet over (klienten skal ikke kunne skille "finnes ikke" fra "utsending feilet"). + +i18n (se plan): `locale` sendes av klienten ved forespørsel (frontend vet sitt +eget visningsspråk), lagres på token-raden, og brukes BÅDE til å velge språk +på selve e-posten OG til å sette en HELT NY brukers `preferred_locale`. En +EKSISTERENDE bruker som ber om en ny lenke får IKKE sin lagrede preferanse +overskrevet -- kun e-posten sendes på forespørselens språk. """ import hashlib import secrets import traceback from datetime import datetime, timedelta, timezone +from typing import Literal -from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi import APIRouter, Depends, Request, Response from pydantic import BaseModel, EmailStr from ..auth import CurrentUser, SESSION_COOKIE_NAME, create_session_token, get_current_user, should_use_secure_cookies from ..config import settings from ..db import org_connection, plain_connection from ..email import send_magic_link_email +from ..errors import app_error router = APIRouter(prefix="/auth", tags=["auth"]) @@ -46,6 +54,7 @@ def _hash_token(token: str) -> str: class MagicLinkRequest(BaseModel): email: EmailStr + locale: Literal["nb", "en"] = "nb" @router.post("/request-link") @@ -75,16 +84,17 @@ async def request_magic_link(body: MagicLinkRequest) -> dict: raw_token = secrets.token_urlsafe(32) expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES) await conn.execute( - "INSERT INTO magic_link_token (email, token_hash, expires_at) VALUES ($1, $2, $3)", + "INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, $4)", email, _hash_token(raw_token), expires_at, + body.locale, ) if settings.DEV_LOG_MAGIC_LINKS: - print(f"[DEV] Magic link for {email}: {raw_token}", flush=True) + print(f"[DEV] Magic link for {email} ({body.locale}): {raw_token}", flush=True) elif settings.SMTP_CONFIGURED: try: - await send_magic_link_email(email, raw_token) + await send_magic_link_email(email, raw_token, body.locale) except Exception: # Se modul-docstring: driftsfeil skal ALDRI endre klientens # respons. Token-raden består og utløper naturlig hvis den @@ -109,6 +119,7 @@ class SessionUser(BaseModel): id: str email: str display_name: str + preferred_locale: str @router.post("/verify-link", response_model=SessionUser) @@ -119,37 +130,42 @@ async def verify_magic_link( async with plain_connection() as conn: # Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to # samtidige forsøk med samme lenke kan da ikke begge lykkes. - email = await conn.fetchval( + token_row = await conn.fetchrow( """ UPDATE magic_link_token SET consumed_at = now() WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now() - RETURNING email + RETURNING email, locale """, token_hash, ) - if email is None: - raise HTTPException( - status.HTTP_401_UNAUTHORIZED, detail="Lenken er ugyldig, brukt eller utløpt." - ) + if token_row is None: + raise app_error(401, "INVALID_MAGIC_LINK", "Lenken er ugyldig, brukt eller utløpt.") + email = token_row["email"] placeholder_name = email.split("@")[0] user_id = await conn.fetchval( """ - INSERT INTO app_user (email, display_name) - VALUES ($1, $2) + INSERT INTO app_user (email, display_name, preferred_locale) + VALUES ($1, $2, $3) ON CONFLICT (email) WHERE email IS NOT NULL DO NOTHING RETURNING id """, email, body.display_name or placeholder_name, + token_row["locale"], ) if user_id is None: - # En annen samtidig verifisering for samme e-post vant innsettingen. + # En annen samtidig verifisering for samme e-post vant innsettingen + # (eller brukeren fantes allerede) -- preferred_locale røres IKKE + # her, kun ved førstegangsopprettelse (se modul-docstring). user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email) user_row = await conn.fetchrow( - "SELECT id::text AS id, email::text AS email, display_name FROM app_user WHERE id = $1", + """ + SELECT id::text AS id, email::text AS email, display_name, preferred_locale + FROM app_user WHERE id = $1 + """, user_id, ) @@ -182,6 +198,7 @@ class Me(BaseModel): id: str email: str display_name: str + preferred_locale: str organizations: list[MyOrg] @@ -199,7 +216,10 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: # er den eneste måten å gjøre det på uten å endre selve RLS-modellen. async with plain_connection() as conn: user_row = await conn.fetchrow( - "SELECT id::text AS id, email::text AS email, display_name FROM app_user WHERE id = $1", + """ + SELECT id::text AS id, email::text AS email, display_name, preferred_locale + FROM app_user WHERE id = $1 + """, user.user_id, ) membership_rows = await conn.fetch( @@ -219,5 +239,6 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: id=user_row["id"], email=user_row["email"], display_name=user_row["display_name"], + preferred_locale=user_row["preferred_locale"], organizations=organizations, ) diff --git a/app/routers/matches.py b/app/routers/matches.py index 57e3e1a..614036f 100644 --- a/app/routers/matches.py +++ b/app/routers/matches.py @@ -11,23 +11,42 @@ Synlighet (ADR-013): motstanderens deltakere er skjult i app-laget (ikke RLS — begge lag er i samme organisasjon) til BEGGE lag har låst for økten. Filteret er lagt i SQL (WHERE ... AND ($revealed OR team_id = ANY($egne_lag))), ikke et etterfølgende Python-filter, slik at lekkasje er strukturelt umulig. + +Klokkeslett (se plan): match.tee_time er UTLEDET, ikke lagret -- session sin +scheduled_at + (sequence-1) * tee_interval_minutes, med en valgfri +tee_time_override som vinner hvis satt. Beregnes i Python i både create_match +og list_matches, som allerede henter økten for andre formål. """ import json +from datetime import datetime, timedelta -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from ..auth import CurrentUser, get_authorized_org, get_current_user from ..blind_draw import locked_team_ids, own_team_ids from ..db import org_connection -from ..errors import translate_db_errors +from ..errors import app_error, translate_db_errors from ..handicap import compute_and_store_side_handicaps, parse_allowance_config from ..team_authz import user_may_act_for_team router = APIRouter() +def _compute_tee_time( + scheduled_at: datetime | None, + tee_interval_minutes: int | None, + sequence: int, + override: datetime | None, +) -> datetime | None: + if override is not None: + return override + if scheduled_at is None or tee_interval_minutes is None: + return None + return scheduled_at + timedelta(minutes=(sequence - 1) * tee_interval_minutes) + + class MatchParticipantOut(BaseModel): id: str team_side: str @@ -51,6 +70,7 @@ class MatchOut(BaseModel): status_text: str | None points_side_a: float | None points_side_b: float | None + tee_time: datetime | None participants: list[MatchParticipantOut] @@ -66,10 +86,14 @@ async def create_match( ) -> MatchOut: async with org_connection(organization_id) as conn, translate_db_errors(): session = await conn.fetchrow( - "SELECT tournament_id::text AS tournament_id FROM session WHERE id = $1", session_id + """ + SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes + FROM session WHERE id = $1 + """, + session_id, ) if session is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Økten finnes ikke.") + raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") # Matchens FK garanterer bare at lagene er gyldige lag i ORG-en, ikke i # DENNE turneringen — sjekk det eksplisitt her. Stoler på DB CHECK @@ -80,10 +104,7 @@ async def create_match( [body.team_a_id, body.team_b_id], ) if team_count != 2: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="Begge lagene må tilhøre øktens turnering.", - ) + raise app_error(400, "VALIDATION_FAILED", "Begge lagene må tilhøre øktens turnering.") row = await conn.fetchrow( """ @@ -91,7 +112,7 @@ async def create_match( VALUES ($1, $2, $3, $4, $5) RETURNING id::text, sequence, team_a_id::text, team_b_id::text, status_text, points_side_a::float AS points_side_a, - points_side_b::float AS points_side_b + points_side_b::float AS points_side_b, tee_time_override """, organization_id, session_id, @@ -99,7 +120,10 @@ async def create_match( body.team_a_id, body.team_b_id, ) - return MatchOut(**dict(row), participants=[]) + tee_time = _compute_tee_time( + session["scheduled_at"], session["tee_interval_minutes"], row["sequence"], row["tee_time_override"] + ) + return MatchOut(**dict(row), tee_time=tee_time, participants=[]) @router.get( @@ -113,16 +137,20 @@ async def list_matches( ) -> list[MatchOut]: async with org_connection(organization_id) as conn: session = await conn.fetchrow( - "SELECT tournament_id::text AS tournament_id FROM session WHERE id = $1", session_id + """ + SELECT tournament_id::text AS tournament_id, scheduled_at, tee_interval_minutes + FROM session WHERE id = $1 + """, + session_id, ) if session is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Økten finnes ikke.") + raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") matches = await conn.fetch( """ SELECT id::text, sequence, team_a_id::text, team_b_id::text, status_text, points_side_a::float AS points_side_a, - points_side_b::float AS points_side_b + points_side_b::float AS points_side_b, tee_time_override FROM match WHERE session_id = $1 ORDER BY sequence @@ -175,6 +203,9 @@ async def list_matches( status_text=m["status_text"], points_side_a=m["points_side_a"], points_side_b=m["points_side_b"], + tee_time=_compute_tee_time( + session["scheduled_at"], session["tee_interval_minutes"], m["sequence"], m["tee_time_override"] + ), participants=by_match[m["id"]], ) for m in matches @@ -208,7 +239,7 @@ async def add_participant( match_id, ) if match is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.") + raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") expected_team_id = match["team_a_id"] if body.team_side == "a" else match["team_b_id"] @@ -216,11 +247,10 @@ async def add_participant( "SELECT team_id::text FROM team_roster WHERE id = $1", body.team_roster_id ) if roster_team_id is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Roster-oppføringen finnes ikke.") + raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.") if roster_team_id != expected_team_id: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="team_roster_id tilhører ikke laget på angitt side i denne matchen.", + raise app_error( + 400, "MISMATCHED_SIDE", "team_roster_id tilhører ikke laget på angitt side i denne matchen." ) session = await conn.fetchrow( @@ -235,20 +265,15 @@ async def add_participant( "SELECT course_id::text FROM tee WHERE id = $1", body.tee_id ) if tee_course_id is None or tee_course_id != session["course_id"]: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, detail="tee_id tilhører ikke øktens bane." - ) + raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke øktens bane.") if not await user_may_act_for_team(conn, expected_team_id, user.user_id): - raise HTTPException( - status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget." - ) + raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.") locked = await locked_team_ids(conn, match["session_id"]) if expected_team_id in locked: - raise HTTPException( - status.HTTP_409_CONFLICT, - detail="Laget har allerede låst oppstillingen for denne økten.", + raise app_error( + 409, "ALREADY_LOCKED", "Laget har allerede låst oppstillingen for denne økten." ) row = await conn.fetchrow( @@ -311,12 +336,10 @@ async def lock_lineup( async with org_connection(organization_id) as conn, translate_db_errors(): session = await conn.fetchrow("SELECT id FROM session WHERE id = $1", session_id) if session is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Økten finnes ikke.") + raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") if not await user_may_act_for_team(conn, body.team_id, user.user_id): - raise HTTPException( - status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget." - ) + raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.") # UNIQUE(session_id, team_id) gir 409 via translate_db_errors ved # dobbel-lås — ingen manuell sjekk nødvendig. diff --git a/app/routers/scoring.py b/app/routers/scoring.py index d982f1f..8ca1e97 100644 --- a/app/routers/scoring.py +++ b/app/routers/scoring.py @@ -31,14 +31,14 @@ forskjøvet holes_remaining/is_dormie/is_closed for resten av matchen. import json -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from handicap_engine import HoleResult, allocate_over_played_holes, compute_match_state from ..auth import CurrentUser, get_authorized_org, get_current_user from ..db import org_connection -from ..errors import translate_db_errors +from ..errors import app_error, translate_db_errors from ..handicap import parse_allowance_config, relative_strokes_for_match from ..team_authz import user_may_act_for_team @@ -226,34 +226,32 @@ async def submit_hole_score( match_id, ) if match is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.") + raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") if match["points_side_a"] is not None: - raise HTTPException( - status.HTTP_409_CONFLICT, - detail="Matchen er avgjort og kan ikke lenger endres.", - ) + raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.") if match["scoring_mode"] != "stroke": - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="Denne økten bruker hull-resultat-modus, ikke slagregistrering.", + raise app_error( + 400, + "WRONG_SCORING_MODE", + "Denne økten bruker hull-resultat-modus, ikke slagregistrering.", ) played = _played_hole_numbers(match["hole_config"]) if body.hole_number not in played: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, detail="Hullnummeret er utenfor øktens spilte omfang." - ) + raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.") is_individual = match["format"] in _INDIVIDUAL_FORMATS if is_individual and body.match_participant_id is None: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="Dette formatet krever match_participant_id (individuell ball).", + raise app_error( + 400, + "WRONG_PARTICIPANT_MODE", + "Dette formatet krever match_participant_id (individuell ball).", ) if not is_individual and body.match_participant_id is not None: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="Dette formatet bruker delt ball -- oppgi ikke match_participant_id.", + raise app_error( + 400, + "WRONG_PARTICIPANT_MODE", + "Dette formatet bruker delt ball -- oppgi ikke match_participant_id.", ) expected_team_id = match["team_a_id"] if body.team_side == "a" else match["team_b_id"] @@ -268,16 +266,14 @@ async def submit_hole_score( match_id, ) if participant is None: - raise HTTPException( - status.HTTP_404_NOT_FOUND, detail="Deltakeren finnes ikke i denne matchen." - ) + raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke i denne matchen.") if participant["team_side"] != body.team_side: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, detail="match_participant_id tilhører ikke angitt side." + raise app_error( + 400, "MISMATCHED_SIDE", "match_participant_id tilhører ikke angitt side." ) if not await user_may_act_for_team(conn, expected_team_id, user.user_id): - raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på dette laget.") + raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.") if body.match_participant_id is not None: row = await conn.fetchrow( @@ -352,23 +348,19 @@ async def submit_hole_result( match_id, ) if match is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.") + raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") if match["points_side_a"] is not None: - raise HTTPException( - status.HTTP_409_CONFLICT, - detail="Matchen er avgjort og kan ikke lenger endres.", - ) + raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.") if match["scoring_mode"] != "hole_result": - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="Denne økten bruker slagregistrering, ikke hull-resultat-modus.", + raise app_error( + 400, + "WRONG_SCORING_MODE", + "Denne økten bruker slagregistrering, ikke hull-resultat-modus.", ) played = _played_hole_numbers(match["hole_config"]) if body.hole_number not in played: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, detail="Hullnummeret er utenfor øktens spilte omfang." - ) + raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.") # Begge lag kan rapportere et hull-resultat (hvilken som helst side kan # vinne/tape/dele) -- brukeren må være rostret på ETT av de to lagene. @@ -376,8 +368,8 @@ async def submit_hole_result( await user_may_act_for_team(conn, match["team_a_id"], user.user_id) or await user_may_act_for_team(conn, match["team_b_id"], user.user_id) ): - raise HTTPException( - status.HTTP_403_FORBIDDEN, detail="Du er ikke rostret på noen av lagene i denne matchen." + raise app_error( + 403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på noen av lagene i denne matchen." ) row = await conn.fetchrow( @@ -435,7 +427,7 @@ async def get_scorecard( match_id, ) if match is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Matchen finnes ikke.") + raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.") results = await _compute_hole_results(conn, match_id, match) played = _played_hole_numbers(match["hole_config"]) diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 7435368..021a92f 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -8,30 +8,35 @@ rostret på et lag som ikke finnes ennå). """ import json +from datetime import date, datetime -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from ..auth import get_authorized_org from ..blind_draw import locked_team_ids from ..db import org_connection -from ..errors import translate_db_errors +from ..errors import app_error, translate_db_errors router = APIRouter() # --------------------------------------------------------------------------- -# Turneringer (flyttet uendret fra tidligere main.py) +# Turneringer # --------------------------------------------------------------------------- class TournamentCreate(BaseModel): name: str = Field(min_length=1, max_length=200) + start_date: date | None = None + end_date: date | None = None class Tournament(BaseModel): id: str name: str status: str + start_date: date | None + end_date: date | None @router.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament]) @@ -40,9 +45,12 @@ async def list_tournaments( ) -> list[Tournament]: async with org_connection(organization_id) as conn: rows = await conn.fetch( - "SELECT id::text, name, status::text FROM tournament ORDER BY created_at DESC" + """ + SELECT id::text, name, status::text, start_date, end_date + FROM tournament ORDER BY created_at DESC + """ ) - return [Tournament(id=r["id"], name=r["name"], status=r["status"]) for r in rows] + return [Tournament(**dict(r)) for r in rows] @router.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201) @@ -50,17 +58,19 @@ async def create_tournament( body: TournamentCreate, organization_id: str = Depends(get_authorized_org), ) -> Tournament: - async with org_connection(organization_id) as conn: + async with org_connection(organization_id) as conn, translate_db_errors(): row = await conn.fetchrow( """ - INSERT INTO tournament (organization_id, name) - VALUES ($1, $2) - RETURNING id::text, name, status::text + INSERT INTO tournament (organization_id, name, start_date, end_date) + VALUES ($1, $2, $3, $4) + RETURNING id::text, name, status::text, start_date, end_date """, organization_id, body.name, + body.start_date, + body.end_date, ) - return Tournament(id=row["id"], name=row["name"], status=row["status"]) + return Tournament(**dict(row)) # --------------------------------------------------------------------------- @@ -111,15 +121,14 @@ async def create_team( organization_id, ) if locked is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Turneringen finnes ikke.") + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") existing = await conn.fetchval( "SELECT count(*) FROM team WHERE tournament_id = $1", tournament_id ) if existing >= 2: - raise HTTPException( - status.HTTP_409_CONFLICT, - detail="Turneringen har allerede to lag (ADR-011: nøyaktig to lag).", + raise app_error( + 409, "LIMIT_REACHED", "Turneringen har allerede to lag (ADR-011: nøyaktig to lag)." ) row = await conn.fetchrow( @@ -189,7 +198,7 @@ async def add_roster_entry( async with org_connection(organization_id) as conn, translate_db_errors(): team = await conn.fetchrow("SELECT tournament_id FROM team WHERE id = $1", team_id) if team is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Laget finnes ikke.") + raise app_error(404, "NOT_FOUND", "Laget finnes ikke.") # Skjemaet håndhever kun unikhet PER lag (UNIQUE(team_id, player_id)). # Uten denne sjekken kunne en spiller rostres på BEGGE lag i samme @@ -209,9 +218,10 @@ async def add_roster_entry( team_id, ) if already_elsewhere: - raise HTTPException( - status.HTTP_409_CONFLICT, - detail="Spilleren er allerede rostret på det andre laget i denne turneringen.", + raise app_error( + 409, + "DUPLICATE", + "Spilleren er allerede rostret på det andre laget i denne turneringen.", ) snapshot = body.handicap_index_snapshot @@ -257,6 +267,11 @@ class SessionCreate(BaseModel): # ADR-012: 'stroke' (hole_score, motoren utleder netto) eller 'hole_result' # (match_hole_result, bare hvem som vant hullet). DB-default er 'stroke'. scoring_mode: str = "stroke" + # Klokkeslett-modell (se plan): én starttid + ett intervall per økt. + # match.tee_time utledes av disse i matches.py, IKKE lagret per match. + scheduled_at: datetime | None = None + tee_interval_minutes: int | None = None + start_hole: int = 1 class SessionOut(BaseModel): @@ -269,6 +284,9 @@ class SessionOut(BaseModel): points_per_match: float allowance_override: dict | None scoring_mode: str + scheduled_at: datetime | None + tee_interval_minutes: int | None + start_hole: int locked_team_ids: list[str] revealed: bool @@ -284,6 +302,9 @@ def _session_out(row, locked: set[str]) -> SessionOut: points_per_match=row["points_per_match"], allowance_override=json.loads(row["allowance_override"]) if row["allowance_override"] else None, scoring_mode=row["scoring_mode"], + scheduled_at=row["scheduled_at"], + tee_interval_minutes=row["tee_interval_minutes"], + start_hole=row["start_hole"], locked_team_ids=sorted(locked), revealed=len(locked) >= 2, ) @@ -302,7 +323,8 @@ async def list_sessions( """ SELECT id::text, sequence, name, format, hole_config::text AS hole_config, course_id::text, points_per_match::float AS points_per_match, - allowance_override::text AS allowance_override, scoring_mode + allowance_override::text AS allowance_override, scoring_mode, + scheduled_at, tee_interval_minutes, start_hole FROM session WHERE tournament_id = $1 ORDER BY sequence @@ -332,11 +354,13 @@ async def create_session( """ INSERT INTO session (organization_id, tournament_id, sequence, name, format, - hole_config, course_id, points_per_match, allowance_override, scoring_mode) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10) + hole_config, course_id, points_per_match, allowance_override, scoring_mode, + scheduled_at, tee_interval_minutes, start_hole) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11, $12, $13) RETURNING id::text, sequence, name, format, hole_config::text AS hole_config, course_id::text, points_per_match::float AS points_per_match, - allowance_override::text AS allowance_override, scoring_mode + allowance_override::text AS allowance_override, scoring_mode, + scheduled_at, tee_interval_minutes, start_hole """, organization_id, tournament_id, @@ -348,5 +372,8 @@ async def create_session( body.points_per_match, allowance_json, body.scoring_mode, + body.scheduled_at, + body.tee_interval_minutes, + body.start_hole, ) return _session_out(row, set()) diff --git a/docker-compose.yml b/docker-compose.yml index 432c83d..a9dd1e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,7 +3,11 @@ services: build: . container_name: teecup_api environment: - TEECUP_DATABASE_URL: ${TEECUP_DATABASE_URL} + TEECUP_DB_HOST: ${TEECUP_DB_HOST} + TEECUP_DB_PORT: ${TEECUP_DB_PORT} + TEECUP_DB_NAME: ${TEECUP_DB_NAME} + TEECUP_DB_USER: ${TEECUP_DB_USER} + TEECUP_DB_PASS: ${TEECUP_DB_PASS} TEECUP_SESSION_SECRET: ${TEECUP_SESSION_SECRET} TEECUP_SMTP_SERVER: ${TEECUP_SMTP_SERVER} TEECUP_SMTP_PORT: ${TEECUP_SMTP_PORT} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..97eb8c0 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,15 @@ +# v0 sandbox internal files +__v0_runtime_loader.js +__v0_devtools.tsx +__v0_jsx-dev-runtime.ts +.snowflake/ +.v0-trash/ +.vercel/ + +# Environment variables +.env*.local + +# Common ignores +node_modules +.next/ +.DS_Store \ No newline at end of file diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..253cc7e --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,172 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; +@import 'shadcn/tailwind.css'; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --font-sans: var(--font-nunito), ui-sans-serif, system-ui, sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --color-brand-orange: var(--brand-orange); + --color-brand-orange-foreground: var(--brand-orange-foreground); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + color-scheme: light; + --background: oklch(0.985 0.005 130); + --foreground: oklch(0.24 0.02 145); + --card: oklch(1 0 0); + --card-foreground: oklch(0.24 0.02 145); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.24 0.02 145); + --primary: oklch(0.7512 0.1613 130.33); + --primary-foreground: oklch(0.22 0.04 140); + --secondary: oklch(0.96 0.02 130); + --secondary-foreground: oklch(0.3 0.03 140); + --muted: oklch(0.96 0.01 130); + --muted-foreground: oklch(0.52 0.02 140); + --accent: oklch(0.94 0.05 130); + --accent-foreground: oklch(0.3 0.04 140); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.9 0.015 130); + --input: oklch(0.9 0.015 130); + --ring: oklch(0.7512 0.1613 130.33); + --brand-orange: oklch(0.6792 0.2128 36.53); + --brand-orange-foreground: oklch(0.99 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + color-scheme: dark; + --background: oklch(0.19 0.02 150); + --foreground: oklch(0.96 0.01 130); + --card: oklch(0.24 0.02 150); + --card-foreground: oklch(0.96 0.01 130); + --popover: oklch(0.24 0.02 150); + --popover-foreground: oklch(0.96 0.01 130); + --primary: oklch(0.7512 0.1613 130.33); + --primary-foreground: oklch(0.2 0.04 140); + --secondary: oklch(0.28 0.02 150); + --secondary-foreground: oklch(0.96 0.01 130); + --muted: oklch(0.28 0.02 150); + --muted-foreground: oklch(0.7 0.02 135); + --accent: oklch(0.32 0.04 140); + --accent-foreground: oklch(0.96 0.01 130); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 12%); + --input: oklch(1 0 0 / 16%); + --ring: oklch(0.7512 0.1613 130.33); + --brand-orange: oklch(0.6792 0.2128 36.53); + --brand-orange-foreground: oklch(0.16 0.02 40); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@media (prefers-color-scheme: dark) { + :root:not(.light) { + color-scheme: dark; + --background: oklch(0.19 0.02 150); + --foreground: oklch(0.96 0.01 130); + --card: oklch(0.24 0.02 150); + --card-foreground: oklch(0.96 0.01 130); + --popover: oklch(0.24 0.02 150); + --popover-foreground: oklch(0.96 0.01 130); + --primary: oklch(0.7512 0.1613 130.33); + --primary-foreground: oklch(0.2 0.04 140); + --secondary: oklch(0.28 0.02 150); + --secondary-foreground: oklch(0.96 0.01 130); + --muted: oklch(0.28 0.02 150); + --muted-foreground: oklch(0.7 0.02 135); + --accent: oklch(0.32 0.04 140); + --accent-foreground: oklch(0.96 0.01 130); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 12%); + --input: oklch(1 0 0 / 16%); + --ring: oklch(0.7512 0.1613 130.33); + --brand-orange: oklch(0.6792 0.2128 36.53); + --brand-orange-foreground: oklch(0.16 0.02 40); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + } +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..c98533a --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,56 @@ +import { Analytics } from '@vercel/analytics/next' +import type { Metadata, Viewport } from 'next' +import { Nunito } from 'next/font/google' +import './globals.css' + +const nunito = Nunito({ + subsets: ['latin'], + variable: '--font-nunito', + display: 'swap', +}) + +export const metadata: Metadata = { + title: 'TeeCup — Logg inn', + description: 'Logg inn på TeeCup, din golfturnerings-app. Passordløs innlogging med magisk lenke.', + generator: 'v0.app', + icons: { + icon: [ + { + url: '/icon-light-32x32.png', + media: '(prefers-color-scheme: light)', + }, + { + url: '/icon-dark-32x32.png', + media: '(prefers-color-scheme: dark)', + }, + { + url: '/icon.svg', + type: 'image/svg+xml', + }, + ], + apple: '/apple-icon.png', + }, +} + +export const viewport: Viewport = { + colorScheme: 'light dark', + themeColor: [ + { media: '(prefers-color-scheme: light)', color: '#8BC24A' }, + { media: '(prefers-color-scheme: dark)', color: '#1c261d' }, + ], +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + {process.env.NODE_ENV === 'production' && } + + + ) +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..540a92f --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,22 @@ +import { LoginForm, Wordmark } from "@/components/login-form" + +export default function Page() { + return ( +
+
+
+ +

+ Logg inn for å følge turneringen din live. +

+
+ + + +

+ Ved å logge inn godtar du våre vilkår og personvernregler. +

+
+
+ ) +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..45921ec --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/frontend/components/login-form.tsx b/frontend/components/login-form.tsx new file mode 100644 index 0000000..a2a4787 --- /dev/null +++ b/frontend/components/login-form.tsx @@ -0,0 +1,202 @@ +"use client" + +import type React from "react" +import { useEffect, useRef, useState } from "react" +import { Flag, Mail, ArrowLeft, CheckCircle2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +const RESEND_COOLDOWN = 30 // seconds + +function isValidEmail(value: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) +} + +export function LoginForm() { + const [email, setEmail] = useState("") + const [touched, setTouched] = useState(false) + const [sent, setSent] = useState(false) + const [sending, setSending] = useState(false) + const [cooldown, setCooldown] = useState(0) + + const emailValid = isValidEmail(email) + const showError = touched && email.length > 0 && !emailValid + + // Countdown timer for the resend cooldown. + useEffect(() => { + if (cooldown <= 0) return + const id = setInterval(() => { + setCooldown((c) => (c <= 1 ? 0 : c - 1)) + }, 1000) + return () => clearInterval(id) + }, [cooldown]) + + async function sendLink() { + // Mock only — pretend to dispatch a magic link. + setSending(true) + await new Promise((r) => setTimeout(r, 700)) + setSending(false) + setSent(true) + setCooldown(RESEND_COOLDOWN) + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setTouched(true) + if (!emailValid || sending) return + void sendLink() + } + + function handleResend() { + if (cooldown > 0 || sending) return + void sendLink() + } + + function handleReset() { + setSent(false) + setTouched(false) + } + + return ( +
+ {sent ? ( + + ) : ( +
+
+ +
+
+ {showError && ( +

+ Skriv inn en gyldig e-postadresse. +

+ )} +
+ + + +

+ Ingen passord. Vi sender deg en sikker lenke på e-post. +

+
+ )} +
+ ) +} + +function ConfirmationState({ + email, + cooldown, + sending, + onResend, + onReset, +}: { + email: string + cooldown: number + sending: boolean + onResend: () => void + onReset: () => void +}) { + const liveRef = useRef(null) + + useEffect(() => { + liveRef.current?.focus() + }, []) + + return ( +
+
+
+ +
+

+ Sjekk innboksen din +

+

+ Vi har sendt en innloggingslenke til{" "} + {email}. Åpne den på denne enheten for å logge inn. +

+
+ +
+ {cooldown > 0 ? ( +

+ Fikk du ingen e-post? Send på nytt om{" "} + {cooldown}s +

+ ) : ( + + )} +
+ + +
+ ) +} + +export function Wordmark() { + return ( +
+
+
+

+ TeeCup +

+
+ ) +} diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx new file mode 100644 index 0000000..b520e87 --- /dev/null +++ b/frontend/components/ui/button.tsx @@ -0,0 +1,58 @@ +import { Button as ButtonPrimitive } from '@base-ui/react/button' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + outline: + 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground', + ghost: + 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50', + destructive: + 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: + 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', + icon: 'size-8', + 'icon-xs': + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': + 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg', + 'icon-lg': 'size-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +function Button({ + className, + variant = 'default', + size = 'default', + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx new file mode 100644 index 0000000..5d76ebc --- /dev/null +++ b/frontend/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/frontend/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/frontend/components/ui/label.tsx b/frontend/components/ui/label.tsx new file mode 100644 index 0000000..74da65c --- /dev/null +++ b/frontend/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +