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 (
+