Alt rent, ingen byggeartefakter liggende igjen, dokumentasjonen oppdatert. Oppsummering:

Live og verifisert ende-til-ende:

/verify-siden bygget og live, e-postmalen sender nå en ekte klikkbar lenke (app/email.py/app/config.py)
Frontend containerisert (frontend/Dockerfile, standalone Next.js) og rullet ut som egen teecup_frontend-tjeneste
Caddy peker nå teecup.teeoff.no på frontend-en, som selv proxyer API-kall server-side — same-origin, ingen CORS, cookie uendret (dokumentert som ny ADR-016)
Fant og fikset en reell fallgruve: Next.js sin rewrites() bakes inn ved build-tid for standalone-output, ikke lest ved kjøretid — løst med en Docker build-time ARG
Ekte e-post sendt, ekte lenke klikket, sesjon opprettet — bekreftet av deg
Én ting du må huske: Caddy-endringen ligger i det separate /opt/teeoff-repoet (deploy/Caddyfile), ikke i teecup-repoet — den er ikke committet ennå. Lett å glemme siden resten av denne økten kun har jobbet i /opt/teecup. Vil du at jeg minner deg, eller committer du den nå selv i teeoff-repoet?

CLAUDE.md, FEATURE_BACKLOG.md og ARCHITECTURE_DECISIONS.md (ny ADR-016) er oppdatert. Klar for commit i teecup-repoet når du vil.
This commit is contained in:
Erol Haagenrud 2026-07-17 22:28:43 +02:00
parent be8da63df6
commit 59517b9290
13 changed files with 384 additions and 18 deletions

View file

@ -161,7 +161,20 @@
"Bash(git -C /opt/teecup rev-parse --show-toplevel)", "Bash(git -C /opt/teecup rev-parse --show-toplevel)",
"Bash(git -C /opt/teecup config --get core.excludesfile)", "Bash(git -C /opt/teecup config --get core.excludesfile)",
"Bash(git -C /opt/teecup ls-files .claude/)", "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": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -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å) ## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:

View file

@ -3,7 +3,7 @@
Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber. 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) ## 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. - `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 - Endres en beslutning: legg til en ny ADR, ikke slett historikk. Hold begge
filene oppdatert når noe avgjøres. filene oppdatert når noe avgjøres.
@ -253,8 +253,54 @@ Ferdig og verifisert:
delt Caddy-instans, ikke samme risikoklasse som Caddy-hendelsen fra delt Caddy-instans, ikke samme risikoklasse som Caddy-hendelsen fra
containeriseringsrunden). 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: Neste steg:
1. Frontend (PWA, offline-first) og kommunikasjon. Frontend er fortsatt IKKE 1. Flere V0-skjermer (turneringsoversikt, lag/roster, økt/program, blind
startet (⬜ i utviklingsplanen i ARCHITECTURE_DECISIONS.md) — helt frem til draw, scorekort, leaderboard) — samme mønster: design i V0, kvalitetsrunde
nå har API-et vært nåbart, men uten noe grensesnitt en sluttbruker kan (farger/typesjekk/analytics), kable mot ekte API via samme
bruke. 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.

View file

@ -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. | | 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. | | 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. | | 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.
--- ---

View file

@ -67,5 +67,10 @@ class Settings:
FROM_EMAIL: str | None = os.environ.get("TEECUP_FROM_EMAIL", "").strip() or None 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) 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() settings = Settings()

View file

@ -42,7 +42,9 @@ _TEMPLATES = {
"subject": "Logg inn på TeeCup", "subject": "Logg inn på TeeCup",
"body": ( "body": (
"Hei,\n\n" "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" "{token}\n\n"
"Ba du ikke om dette, kan du se bort fra e-posten.\n" "Ba du ikke om dette, kan du se bort fra e-posten.\n"
), ),
@ -51,7 +53,9 @@ _TEMPLATES = {
"subject": "Log in to TeeCup", "subject": "Log in to TeeCup",
"body": ( "body": (
"Hi,\n\n" "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" "{token}\n\n"
"If you didn't request this, you can ignore this email.\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: 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
(se plan). Når frontend bygges, oppdater malen til en ekte URL.
`locale` valideres allerede av Pydantic (Literal["nb","en"]) vei inn i `locale` valideres allerede av Pydantic (Literal["nb","en"]) vei inn i
routeren, men faller trygt tilbake til "nb" her også hvis den skulle routeren, men faller trygt tilbake til "nb" her også hvis den skulle
komme fra et annet kall uten den garantien. komme fra et annet kall uten den garantien.
""" """
template = _TEMPLATES.get(locale, _TEMPLATES["nb"]) template = _TEMPLATES.get(locale, _TEMPLATES["nb"])
subject = template["subject"] 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) await to_thread(_send_sync, to_email, subject, body)

View file

@ -18,6 +18,18 @@ services:
- teeoff_default - teeoff_default
restart: unless-stopped 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: networks:
teeoff_default: teeoff_default:
external: true external: true

6
frontend/.dockerignore Normal file
View file

@ -0,0 +1,6 @@
node_modules
.next
.pnpm-store
next-env.d.ts
pnpm-workspace.yaml
.git

28
frontend/Dockerfile Normal file
View file

@ -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"]

View file

@ -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 (
<main className="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-5 py-10">
<div className="flex w-full max-w-sm flex-col gap-8">
<header className="flex flex-col items-center gap-3 text-center">
<Wordmark />
</header>
<Suspense
fallback={
<div className="flex w-full flex-col items-center gap-4 rounded-3xl border border-border bg-card p-8 text-center shadow-lg shadow-black/5">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
}
>
<VerifyForm />
</Suspense>
</div>
</main>
)
}

View file

@ -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<Status>(urlToken ? "checking" : "manual")
const [manualToken, setManualToken] = useState("")
const [email, setEmail] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="flex w-full flex-col items-center gap-4 rounded-3xl border border-border bg-card p-8 text-center shadow-lg shadow-black/5">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
<p className="text-sm text-muted-foreground" role="status">
Logger deg inn
</p>
</div>
)
}
if (status === "success") {
return (
<div className="flex w-full flex-col items-center gap-5 rounded-3xl border border-border bg-card p-6 text-center shadow-lg shadow-black/5 sm:p-8">
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<CheckCircle2 aria-hidden="true" className="size-8 text-primary" />
</div>
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-extrabold tracking-tight text-balance">Du er innlogget</h2>
{email && (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Logget inn som <span className="font-semibold text-foreground">{email}</span>.
</p>
)}
</div>
</div>
)
}
// "manual" eller "error" -- samme skjema, ev. med en feilmelding over knappen.
return (
<div className="w-full rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
<form onSubmit={handleManualSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2">
<Label htmlFor="token" className="text-sm font-semibold">
Kode fra e-posten
</Label>
<div className="relative">
<KeyRound
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="token"
type="text"
autoComplete="one-time-code"
autoFocus
placeholder="Lim inn koden"
value={manualToken}
onChange={(e) => setManualToken(e.target.value)}
className="h-14 rounded-2xl pl-11 text-base"
/>
</div>
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
{error}
</p>
)}
<Button
type="submit"
disabled={!manualToken.trim()}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
Logg inn
</Button>
<a
href="/"
className="text-center text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
Be om en ny lenke
</a>
</form>
</div>
)
}

View file

@ -5,6 +5,7 @@ const API_ORIGIN = process.env.TEECUP_API_ORIGIN || "http://localhost:8000"
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: "standalone",
images: { images: {
unoptimized: true, unoptimized: true,
}, },

View file

@ -1,5 +1,5 @@
{ {
"name": "my-project", "name": "teecup-frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -28,10 +28,5 @@
"postcss": "^8.5", "postcss": "^8.5",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"typescript": "5.7.3" "typescript": "5.7.3"
},
"pnpm": {
"overrides": {
"hono": "4.12.25"
}
} }
} }