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 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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
17
app/email.py
17
app/email.py
|
|
@ -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
|
|
||||||
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
|
`locale` valideres allerede av Pydantic (Literal["nb","en"]) på 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)
|
||||||
|
|
|
||||||
|
|
@ -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
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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue