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:
parent
be8da63df6
commit
59517b9290
13 changed files with 384 additions and 18 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
56
CLAUDE.md
56
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
17
app/email.py
17
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
frontend/.dockerignore
Normal file
6
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
.next
|
||||
.pnpm-store
|
||||
next-env.d.ts
|
||||
pnpm-workspace.yaml
|
||||
.git
|
||||
28
frontend/Dockerfile
Normal file
28
frontend/Dockerfile
Normal 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"]
|
||||
28
frontend/app/verify/page.tsx
Normal file
28
frontend/app/verify/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
138
frontend/components/verify-form.tsx
Normal file
138
frontend/components/verify-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue