diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e5d2ab8..bde1c87 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -161,7 +161,20 @@ "Bash(git -C /opt/teecup rev-parse --show-toplevel)", "Bash(git -C /opt/teecup config --get core.excludesfile)", "Bash(git -C /opt/teecup ls-files .claude/)", - "Bash(git -C /opt/teecup status --porcelain)" + "Bash(git -C /opt/teecup status --porcelain)", + "Bash(grep -rn -A2 '@router\\\\.\\\\\\(get\\\\|post\\\\|patch\\\\|delete\\\\|put\\\\\\)\\($' /opt/teecup/app/routers/matches.py /opt/teecup/app/routers/scoring.py /opt/teecup/app/routers/tournaments.py)", + "Bash(grep -rn -A1 '@router\\\\.\\\\\\(get\\\\|post\\\\|patch\\\\|delete\\\\|put\\\\\\)\\($' /opt/teecup/app/routers/matches.py /opt/teecup/app/routers/scoring.py /opt/teecup/app/routers/tournaments.py)", + "Bash(python3 -m py_compile /opt/teecup/app/config.py /opt/teecup/app/email.py)", + "Bash(docker build *)", + "Bash(docker rmi *)", + "Bash(curl -s -o /dev/null -w 'teecup.teeoff.no/: %{http_code}\\\\n' https://teecup.teeoff.no/)", + "Bash(curl -s https://teecup.teeoff.no/health)", + "Bash(curl -s https://teecup.teeoff.no/)", + "Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/)", + "Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' https://teecup.teeoff.no/verify)", + "Bash(curl -s -X POST https://teecup.teeoff.no/auth/request-link -H 'Content-Type: application/json' -d '{\"email\":\"erol.haagenrud@envide.no\",\"locale\":\"nb\"}')", + "Bash(git -C /opt/teecup status --short frontend/pnpm-lock.yaml)", + "Bash(git -C /opt/teecup diff --stat frontend/pnpm-lock.yaml)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index c30bb65..dc885b0 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -363,6 +363,48 @@ brukerens varige profilinnstilling. --- +## ADR-016 — Frontend som eneste offentlige overflate, API-et server-side proxyet + +**Beslutning:** `teecup.teeoff.no` peker (Caddy) på Next.js-frontenden +(`teecup_frontend`), IKKE direkte på FastAPI-et. Frontenden proxyer selv +kjente API-sti-prefikser (`/auth/*`, `/orgs/*`, `/health` — som til sammen +dekker HELE dagens API-overflate, verifisert med et grep av samtlige +rutedefinisjoner) videre til `teecup_api:8000` server-side, via Next.js sin +egen `rewrites()`-mekanisme (`frontend/next.config.mjs`). API-et er ikke +lenger separat Caddy-rutet eller offentlig eksponert under eget navn. + +**Begrunnelse:** Alt kjører dermed under samme opprinnelse (origin) sett fra +nettleseren — ingen CORS-konfigurasjon trengs, og HttpOnly-sesjonscookien +(ADR-009) fungerer helt uendret enten kallet "egentlig" går til frontend +eller API. Alternativet (eget subdomene for API-et, `SameSite=None`-cookie +eller CORS-hull) ville svekket cookie-sikkerhetsmodellen som allerede var +bevisst bygget stram. Samme mønster generaliserer til alle fremtidige +skjermer uten videre Caddy-endringer — nye API-ruter under `/auth`, `/orgs` +trenger ingen ny proxy-regel, kun nye Next.js-sider som kaller dem med +relative URL-er. + +**Reell fallgruve funnet og fikset ved bygging (2026-07-17):** Next.js sin +`rewrites()` løses ved BUILD-tid for `output: "standalone"` (bakes inn i +server-bunten), ikke ved container-oppstart. En `docker run -e +TEECUP_API_ORIGIN=...` ved kjøretid ble derfor stille ignorert (falt tilbake +til default `localhost:8000`, som ikke fantes i containeren — proxy-kall +feilet med `ECONNREFUSED`). Løst med en Docker build-time `ARG +TEECUP_API_ORIGIN` (default `http://teecup_api:8000`, matcher alltid det +delte nettverkets tjenestenavn) i `frontend/Dockerfile`, satt via +`docker-compose.yml` sin `build.args`. Generell lærdom for fremtidige +Next.js/Docker-oppsett i dette prosjektet: alt som brukes inne i +`next.config.mjs` er en BUILD-tids verdi, ikke en runtime-verdi, med mindre +det eksplisitt leses på nytt et sted som faktisk kjører per request (en +route handler, ikke selve config-filen). + +**Konsekvens:** Enhver fremtidig ny API-sti-prefiks (utenfor `/auth`, +`/orgs`, `/health`) MÅ legges til i `frontend/next.config.mjs` sin +`rewrites()`-liste, ellers blir den utilgjengelig fra nettleseren selv om +API-et selv fungerer (kun nåbar internt på Docker-nettverket). `teecup_api` +sin port er ikke lenger tenkt nåbar direkte utenfra i prod. + +--- + ## Å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 f3fac1d..bf96ac0 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…015). Fasit. +- `ARCHITECTURE_DECISIONS.md` — hva som er bestemt og hvorfor (ADR-001…016). 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. @@ -253,8 +253,54 @@ Ferdig og verifisert: delt Caddy-instans, ikke samme risikoklasse som Caddy-hendelsen fra containeriseringsrunden). +- **Frontend startet, innlogging LIVE (2026-07-17, ADR-016):** første + frontend-skjerm i prosjektet. Designet i V0 (Next.js + Tailwind + + shadcn/ui), hentet inn som `frontend/` — merkevare-form/farge fra + Teeoff-logoen, IKKE navn/logo (egne, separate produkter, se ADR-009). + **Kvalitetsrunde før bruk:** V0s fargetokens var OKLCH-TILNÆRMINGER, ikke + eksakte — regnet ut presise verdier fra `#8bc24a`/`#ff5722` og rettet alle + 6 forekomster i `globals.css`. Fjernet `@vercel/analytics` (unødvendig på + egen-hostet infra), fjernet `typescript: { ignoreBuildErrors: true }` + (ekte typesjekk kjører nå), fjernet dødt `pnpm.overrides`-felt. + `frontend/.gitignore` manglet `.pnpm-store/` — årsaken til at brukerens + VSCode viste 10 000 "ukjente" filer ved første commit-forsøk; rettet. + **Kablet mot ekte API:** `next.config.mjs` sin `rewrites()` proxyer + `/auth/*`/`/orgs/*`/`/health` server-side til `teecup_api` — same-origin, + ingen CORS, cookie uendret (full begrunnelse i ADR-016). Login-skjermet + sender ekte `POST /auth/request-link`; ny `/verify`-side mottar + `?token=...` fra e-postlenken (auto-verifiserer) eller viser et manuelt + "lim inn koden"-felt. `app/email.py` fikk en ny `PUBLIC_BASE_URL`- + innstilling og sender nå en EKTE klikkbar lenke (koden beholdes som + fallback). + **Reell fallgruve funnet og fikset ved containerisering:** Next.js sin + `rewrites()` løses ved BUILD-tid for `output: "standalone"`, ikke ved + container-oppstart — en runtime `-e TEECUP_API_ORIGIN=...` ble stille + ignorert (proxy-kall feilet med `ECONNREFUSED` mot `localhost:8000`). + Løst med en Docker build-time `ARG TEECUP_API_ORIGIN` i + `frontend/Dockerfile`, satt via `docker-compose.yml` sin `build.args`. + **Rullet ut live:** ny `teecup_frontend`-tjeneste i `docker-compose.yml`. + Caddy (`teecup.teeoff.no`, i det SEPARATE `teeoff`-repoet, + `/opt/teeoff/deploy/Caddyfile`) endret fra å peke direkte på `teecup_api` + til å peke på `teecup_frontend` — samme stale-inode-oppførsel som + containeriseringsrunden (graceful `reload` plukket IKKE opp endringen, + `/` fortsatte å gi `teecup_api` sin egen 404 i stedet for innloggingssiden + til reload faktisk skjedde). Løst likt: full `docker restart + teeoff_caddy`, brukeren bekreftet eksplisitt på forhånd. `teeoff.no` + upåvirket gjennom hele omstarten. + **Verifisert med FAKTISK e-postlevering:** ekte magic-link sendt til + brukerens egen adresse over `https://teecup.teeoff.no`, ekte e-post + mottatt med en ekte klikkbar lenke, åpnet i nettleser, landet på en + fungerende `/verify`-side, sesjon opprettet — brukeren bekreftet innlogget + status. Første gang en hel bruker-vendt flyt er bevist ende-til-ende i + produksjon, ikke bare API-et isolert. + **Merk for neste økt:** `deploy/Caddyfile`-endringen ligger uncommitted i + det SEPARATE `/opt/teeoff`-repoet, ikke i `teecup`-repoet — lett å glemme + siden denne økten ellers kun har jobbet i `/opt/teecup`. + Neste steg: -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. +1. Flere V0-skjermer (turneringsoversikt, lag/roster, økt/program, blind + draw, scorekort, leaderboard) — samme mønster: design i V0, kvalitetsrunde + (farger/typesjekk/analytics), kable mot ekte API via samme + rewrites-proxy, ingen nye Caddy-endringer nødvendig (ADR-016). +2. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet. +3. Kommunikasjon (chat/feed) — ikke startet. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index afb0495..b9c1159 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -35,6 +35,53 @@ | 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. | +| Frontend: innlogging + verifisering, LIVE | ✅ | ADR-016. Next.js på `teecup.teeoff.no`, ekte magic-link-flyt bevist med reell e-post. Se egen seksjon under. | + +--- + +### Frontend: innlogging + verifisering — ✅ BYGGET OG VERIFISERT LIVE 2026-07-17 +- Første frontend-skjerm i prosjektet. Designet i V0 (login-skjerm, merkevare + form/farge hentet fra Teeoff-logoen — IKKE navn/logo, se ADR-016s + begrunnelse og tidligere samtale), hentet inn som `frontend/` (Next.js + + Tailwind + shadcn/ui). +- **Kvalitetsrunde på V0-output før bruk:** fargetokens i `globals.css` var + OKLCH-TILNÆRMINGER av de forespurte hex-fargene (`#8bc24a`/`#ff5722`), ikke + eksakte — regnet ut presise OKLCH-ekvivalenter og rettet alle 6 + forekomster (lys/mørk/system-mørk). Fjernet `@vercel/analytics` helt + (ga null verdi på egen-hostet infrastruktur, kun en unødvendig + tredjeparts nettverkskall). Fjernet `typescript: { ignoreBuildErrors: + true }` (bygget kjører nå ekte typesjekk). Fjernet dødt + `pnpm.overrides`-felt. `frontend/.gitignore` manglet `.pnpm-store/` — + årsaken til at VSCode viste 10 000 "ukjente" filer ved første + commit-forsøk; rettet. +- **Kablet mot ekte API** (`app/routers/auth.py` sine `/auth/request-link` + og `/auth/verify-link`): `frontend/next.config.mjs` sin `rewrites()` + proxyer `/auth/*`/`/orgs/*`/`/health` server-side til API-et — se + ADR-016 for hele begrunnelsen (same-origin, ingen CORS, cookie uendret). + `components/login-form.tsx` sender ekte `POST /auth/request-link`; + ny `app/verify/page.tsx` + `components/verify-form.tsx` mottar + `?token=...` fra e-postlenken (automatisk verifisering) ELLER viser et + manuelt "lim inn koden"-felt (nødvendig fallback, ikke overflødig — se + under). +- **Backend-endring i samme runde:** `app/email.py`/`app/config.py` fikk en + ny `PUBLIC_BASE_URL`-innstilling (default `https://teecup.teeoff.no`, + valgfri) — e-postmalen sender nå en EKTE klikkbar lenke + (`{base}/verify?token=...`) i tillegg til den rå koden som fallback + (samme mal-mekanisme som i18n-runden, ADR-015). +- **Containerisert og rullet ut LIVE** (ny `frontend/Dockerfile`, + multi-stage, Next.js `output: "standalone"`; ny `teecup_frontend`-tjeneste + i `docker-compose.yml`). Caddy (`teecup.teeoff.no`, i det SEPARATE + `teeoff`-repoet) peker nå på `teecup_frontend` i stedet for `teecup_api` + direkte — se ADR-016 for hvorfor, og CLAUDE.md-status for + driftsdetaljene (samme stale-Caddy-inode-hendelse som + containeriseringsrunden, løst likt). +- **Verifisert med FAKTISK e-postlevering, ikke bare curl:** ekte + `POST /auth/request-link` sendt til brukerens egen adresse over + `https://teecup.teeoff.no`, ekte e-post mottatt med en ekte klikkbar + lenke, lenken åpnet i nettleser, landet på en fungerende `/verify`-side, + sesjon opprettet — brukeren bekreftet "jeg er tilsynelatende innlogget." + Første gang en hel bruker-vendt flyt (ikke bare API-et isolert) er bevist + ende-til-ende i produksjon. --- diff --git a/app/config.py b/app/config.py index 1496beb..4739b8d 100644 --- a/app/config.py +++ b/app/config.py @@ -67,5 +67,10 @@ class Settings: FROM_EMAIL: str | None = os.environ.get("TEECUP_FROM_EMAIL", "").strip() or None SMTP_CONFIGURED: bool = bool(SMTP_SERVER and SMTP_PORT and SMTP_USER and SMTP_PASS and FROM_EMAIL) + # Offentlig URL-base for lenker i utsendt e-post (frontend, ikke API-et + # direkte). Ikke hemmelig -- valgfri med prod-riktig standard slik at + # scratch-/dev-testing kan overstyre uten kodeendring. + PUBLIC_BASE_URL: str = os.getenv("TEECUP_PUBLIC_BASE_URL", "https://teecup.teeoff.no").rstrip("/") + settings = Settings() diff --git a/app/email.py b/app/email.py index 78b219b..2eb82bf 100644 --- a/app/email.py +++ b/app/email.py @@ -42,7 +42,9 @@ _TEMPLATES = { "subject": "Logg inn på TeeCup", "body": ( "Hei,\n\n" - "Bruk denne koden for å logge inn på TeeCup innen {minutes} minutter:\n\n" + "Åpne denne lenken for å logge inn på TeeCup innen {minutes} minutter:\n\n" + "{link}\n\n" + "Fungerer ikke lenken? Lim inn denne koden manuelt i stedet:\n" "{token}\n\n" "Ba du ikke om dette, kan du se bort fra e-posten.\n" ), @@ -51,7 +53,9 @@ _TEMPLATES = { "subject": "Log in to TeeCup", "body": ( "Hi,\n\n" - "Use this code to log in to TeeCup within {minutes} minutes:\n\n" + "Open this link to log in to TeeCup within {minutes} minutes:\n\n" + "{link}\n\n" + "Link not working? Paste this code in manually instead:\n" "{token}\n\n" "If you didn't request this, you can ignore this email.\n" ), @@ -60,15 +64,16 @@ _TEMPLATES = { 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. + """Sender en klikkbar innloggingslenke (pluss selve koden som fallback). - 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. """ template = _TEMPLATES.get(locale, _TEMPLATES["nb"]) subject = template["subject"] - body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, token=raw_token) + link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}" + body = template["body"].format( + minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, token=raw_token, link=link + ) await to_thread(_send_sync, to_email, subject, body) diff --git a/docker-compose.yml b/docker-compose.yml index a9dd1e2..1e596c1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,18 @@ services: - teeoff_default restart: unless-stopped + teecup_frontend: + build: + context: ./frontend + args: + TEECUP_API_ORIGIN: http://teecup_api:8000 + container_name: teecup_frontend + networks: + - teeoff_default + restart: unless-stopped + depends_on: + - teecup_api + networks: teeoff_default: external: true diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..6d35342 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.pnpm-store +next-env.d.ts +pnpm-workspace.yaml +.git diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..3b827aa --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,28 @@ +FROM node:22-slim AS deps +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --ignore-scripts + +FROM node:22-slim AS builder +WORKDIR /app +RUN corepack enable +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# next.config.mjs sin rewrites() leses ved BUILD-tid for standalone-output, +# ikke ved container-oppstart -- API_ORIGIN må derfor være kjent her, ikke +# bare som en runtime env-var på "docker run". +ARG TEECUP_API_ORIGIN=http://teecup_api:8000 +ENV TEECUP_API_ORIGIN=$TEECUP_API_ORIGIN +RUN pnpm build + +FROM node:22-slim AS runner +WORKDIR /app +ENV NODE_ENV=production + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static + +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/frontend/app/verify/page.tsx b/frontend/app/verify/page.tsx new file mode 100644 index 0000000..708b7da --- /dev/null +++ b/frontend/app/verify/page.tsx @@ -0,0 +1,28 @@ +import { Suspense } from "react" +import { Wordmark } from "@/components/login-form" +import { VerifyForm } from "@/components/verify-form" + +export default function VerifyPage() { + return ( +
+
+
+ +
+ + + + } + > + + +
+
+ ) +} diff --git a/frontend/components/verify-form.tsx b/frontend/components/verify-form.tsx new file mode 100644 index 0000000..5d469f7 --- /dev/null +++ b/frontend/components/verify-form.tsx @@ -0,0 +1,138 @@ +"use client" + +import type React from "react" +import { useEffect, useState } from "react" +import { useSearchParams } from "next/navigation" +import { CheckCircle2, KeyRound } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +type Status = "checking" | "verifying" | "manual" | "success" | "error" + +export function VerifyForm() { + const searchParams = useSearchParams() + const urlToken = searchParams.get("token") + + const [status, setStatus] = useState(urlToken ? "checking" : "manual") + const [manualToken, setManualToken] = useState("") + const [email, setEmail] = useState(null) + const [error, setError] = useState(null) + + async function verify(token: string) { + setStatus("verifying") + setError(null) + try { + const res = await fetch("/auth/verify-link", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ token }), + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + throw new Error(body?.detail?.message ?? "Lenken er ugyldig eller utløpt.") + } + const user = await res.json() + setEmail(user.email) + setStatus("success") + } catch (err) { + setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.") + setStatus("error") + } + } + + useEffect(() => { + if (urlToken) void verify(urlToken) + // Kjør kun ved mount / når URL-tokenet faktisk endres. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [urlToken]) + + function handleManualSubmit(e: React.FormEvent) { + e.preventDefault() + if (!manualToken.trim()) return + void verify(manualToken.trim()) + } + + if (status === "checking" || status === "verifying") { + return ( +
+ + ) + } + + if (status === "success") { + return ( +
+
+
+
+

Du er innlogget

+ {email && ( +

+ Logget inn som {email}. +

+ )} +
+
+ ) + } + + // "manual" eller "error" -- samme skjema, ev. med en feilmelding over knappen. + return ( +
+
+
+ +
+
+
+ + {error && ( +

+ {error} +

+ )} + + + + + Be om en ny lenke + +
+
+ ) +} diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 0d4f1af..c8ef96f 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -5,6 +5,7 @@ const API_ORIGIN = process.env.TEECUP_API_ORIGIN || "http://localhost:8000" /** @type {import('next').NextConfig} */ const nextConfig = { + output: "standalone", images: { unoptimized: true, }, diff --git a/frontend/package.json b/frontend/package.json index 2ad63c3..ec6c274 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "my-project", + "name": "teecup-frontend", "version": "0.1.0", "private": true, "scripts": { @@ -28,10 +28,5 @@ "postcss": "^8.5", "tailwindcss": "^4.3.3", "typescript": "5.7.3" - }, - "pnpm": { - "overrides": { - "hono": "4.12.25" - } } } \ No newline at end of file