From 45850a97fae3aa02655fb689697c617cf361e825 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Mon, 20 Jul 2026 11:32:10 +0200 Subject: [PATCH] =?UTF-8?q?Live=20og=20dokumentert.=20/dashboard=20er=20n?= =?UTF-8?q?=C3=A5=20den=20ene,=20felles=20adressen=20for=20enhver=20innlog?= =?UTF-8?q?get=20bruker=20=E2=80=94=20organisator,=20spiller,=20eller=20be?= =?UTF-8?q?gge=20=E2=80=94=20med=20personlig=20profil=20(inkl.=20e-post=20?= =?UTF-8?q?og=20mobil)=20redigerbar=20p=C3=A5=20/account.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +- 016_profile_contact.sql | 33 ++++++ ARCHITECTURE_DECISIONS.md | 53 +++++++++ CLAUDE.md | 52 ++++++++- FEATURE_BACKLOG.md | 21 ++++ app/email.py | 36 ++++++ app/routers/auth.py | 100 +++++++++++++++- frontend/app/verify-email/page.tsx | 28 +++++ frontend/components/account-settings.tsx | 136 +++++++++++++++++++++- frontend/components/verify-email-form.tsx | 100 ++++++++++++++++ 10 files changed, 554 insertions(+), 9 deletions(-) create mode 100644 016_profile_contact.sql create mode 100644 frontend/app/verify-email/page.tsx create mode 100644 frontend/components/verify-email-form.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8052127..afba321 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -329,7 +329,9 @@ "Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teeoff.no)", "Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_player_edit_and_dates.py)", "Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_profile_and_myrounds.py)", - "Bash(curl -s -o /dev/null -w \"account: %{http_code}\\\\n\" https://teecup.teeoff.no/account)" + "Bash(curl -s -o /dev/null -w \"account: %{http_code}\\\\n\" https://teecup.teeoff.no/account)", + "Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_email_mobile.py)", + "Bash(curl -s -o /dev/null -w \"verify-email: %{http_code}\\\\n\" https://teecup.teeoff.no/verify-email)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/016_profile_contact.sql b/016_profile_contact.sql new file mode 100644 index 0000000..8f6892c --- /dev/null +++ b/016_profile_contact.sql @@ -0,0 +1,33 @@ +-- ===================================================================== +-- TeeCup — mobil (m/landsnummer) + verifisert e-postbytte på personlig +-- profil (migrasjon 016) +-- ===================================================================== +-- Oppfølging av ADR-031 samme dag: brukeren påpekte at identifikatoren +-- (e-post) og mobil manglet i profilen. Mobil er et rent, ekstra felt +-- (samme mønster som player.mobile, men splittet i landsnummer+nummer +-- slik brukeren ba om). E-post er derimot INNLOGGINGS-identifikatoren +-- (magic-link-mål) -- kan ikke endres med en enkel PATCH uten å risikere +-- kontokapring via en skrivefeil eller en kapret sesjon. Samme +-- token-hash-og-utløp-mønster som magic_link_token (004), gjenbrukt for +-- et NYTT formål: bekreft eierskap av den NYE adressen FØR den faktisk +-- tas i bruk. +-- ===================================================================== + +\set ON_ERROR_STOP on + +ALTER TABLE app_user ADD COLUMN mobile_country_code text; +ALTER TABLE app_user ADD COLUMN mobile_number text; + +CREATE TABLE email_change_token ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE, + new_email citext NOT NULL, + token_hash text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ON email_change_token (user_id); + +GRANT SELECT, INSERT, UPDATE, DELETE ON email_change_token TO teecup_app; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 95c1c7a..6af8f8c 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -1537,6 +1537,59 @@ CLAUDE.md-status for full byggerunde. --- +## ADR-032: Verifisert e-postbytte + mobil på personlig profil + +Reist av brukeren 2026-07-20, samme dag og rett etter ADR-031: "identifikatoren" +(e-post) manglet i den nye personlige profilen, og mobil (med landsnummer) +burde være en opsjon. + +**Beslutning A — mobil er et rent, enkelt tillegg til `ProfileUpdate` +(samme PATCH som resten av profilen), splittet i to felt.** +`mobile_country_code` (f.eks. `"+47"`) og `mobile_number` er separate +kolonner på `app_user` (migrasjon `016_profile_contact.sql`) — ikke én +sammensatt streng — slik at frontend kan tilby en egen landsnummer- +velger uten å måtte parse en fritekststreng i etterkant. + +**Beslutning B — e-post er IKKE en del av den vanlige profil-PATCH-en, og +kan det aldri bli.** E-post er innloggings-identifikatoren (magic-link- +mål) — en enkel PATCH (som de andre feltene) ville latt en skrivefeil +ELLER en kapret sesjon stjele kontoen for godt, ingen verifisering av at +den NYE adressen faktisk eies av noen. Løst med et eget, to-stegs +bekreftelsesløp, samme `token_hash`+`expires_at`+`consumed_at`-mønster +som `magic_link_token` (004), gjenbrukt for et nytt formål: ny tabell +`email_change_token` (bruker_id, ny e-post, token-hash, utløp). +`POST /auth/profile/email` (krever gyldig sesjon — du må bevise at du +ER kontoen i dag) genererer tokenet og sender en bekreftelseslenke til +DEN NYE adressen (ikke den gamle — beviser eierskap av MÅLET, ikke bare +at avsenderen fortsatt er innlogget). `POST /auth/profile/email/confirm` +(ingen sesjon påkrevd — samme mønster som selve magic-link-verifiseringen, +siden lenken kan åpnes på en annen enhet/nettleser enn den som ba om +byttet) forbruker tokenet atomisk og gjennomfører selve byttet. E-posten +endres IKKE før lenken faktisk åpnes. + +**Konsekvens:** duplikat-sjekk (er den ønskede adressen allerede en annen +kontos?) gjøres TO ganger — én gang ved forespørsel (rask +tilbakemelding), én gang igjen rett før selve `UPDATE`-en ved bekreftelse +(kan ha blitt tatt av noen andre i mellomtiden) — pluss den eksisterende +unike indeksen (`app_user_email_unique`, migrasjon 004) som siste +bakstopper via `translate_db_errors()`. + +**Scratch-verifisert, 10 sjekker:** mobil satt via vanlig PATCH; vanlig +profil-PATCH endrer aldri e-post; bytte til en allerede brukt adresse +avvist (409); e-post FORBLIR uendret helt til lenken bekreftes; ugyldig +bekreftelseskode avvist; gyldig kode fullfører byttet; SAMME kode kan +ikke brukes to ganger; en helt ny innlogging med den GAMLE adressen +oppretter nå en fersk, tom konto (beviser byttet er reelt og fullstendig, +ikke kosmetisk). Ekte typesjekket produksjonsbuild kjørt og bekreftet +(ny `/verify-email`-rute listet). + +**Status: ✅ BYGGET OG LIVE 2026-07-20.** Migrasjon 016 kjørt mot ekte +`teecup_db`, bruker bekreftet eksplisitt. Begge containere redeployet, +`/health`/`/dashboard`/`/account`/`/verify-email` → 200, `teeoff.no` +upåvirket. + +--- + ## Å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 c07e2f4..49ad700 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2023,11 +2023,55 @@ Ferdig og verifisert: teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`/ `/account` → 200, `teeoff.no` upåvirket. +- **Oppfølging samme dag: e-post + mobil, BYGGET OG SCRATCH-VERIFISERT + (ADR-032):** brukeren påpekte rett etter forrige runde at "identifikatoren" + (e-post) manglet i profilen, og etterspurte mobil med landsnummer. + Spurte samtidig hvorfor V0 ikke brukes til det visuelle her — svarte at + jeg ikke har V0 som et verktøy jeg selv kan kalle (all V0-bruk i + prosjektet har vært brukeren som designer i v0.app og sender meg + zip-eksporter), og at disse siste tilføyelsene er små, inkrementelle + skjemafelt i eksisterende komponenter (gjenbruker allerede etablerte + Tailwind/shadcn-mønstre) der en full V0-runde (design→eksport→diff→ + sammenslåing) ville vært en unødvendig omvei. + **Mobil:** `mobile_country_code`+`mobile_number` (to separate felt, ikke + én sammensatt streng), lagt til i den EKSISTERENDE `PATCH /auth/profile` + — ren tilføyelse, ingen ny sikkerhetsvurdering nødvendig. + **E-post — bevisst IKKE en enkel PATCH:** e-post er innloggings- + identifikatoren (magic-link-mål) — en vanlig PATCH ville latt en + skrivefeil eller en kapret sesjon stjele kontoen for godt. Bygget som et + ekte to-stegs bekreftelsesløp i stedet, samme `token_hash`+ + `expires_at`+`consumed_at`-mønster som `magic_link_token` (migrasjon + 004): ny `email_change_token`-tabell (migrasjon `016_profile_ + contact.sql`). `POST /auth/profile/email` (krever sesjon, sender + bekreftelseslenke til den NYE adressen -- ikke den gamle, beviser + eierskap av MÅLET). `POST /auth/profile/email/confirm` (ingen sesjon + påkrevd, samme mønster som selve magic-link-verifiseringen -- lenken + kan åpnes på en annen enhet enn den som ba om byttet). E-posten endres + ALDRI før lenken faktisk åpnes. Duplikat-sjekk kjøres TO GANGER (ved + forespørsel og rett før selve byttet, i tilfelle adressen ble tatt i + mellomtiden), pluss den eksisterende unike indeksen som siste + bakstopper. Ny `/verify-email`-side (samme mønster som `/verify`). + **Scratch-verifisert, 10 sjekker:** mobil satt via vanlig PATCH; vanlig + profil-PATCH rører aldri e-post; bytte til allerede brukt adresse + avvist (409); e-post uendret helt til bekreftelse; ugyldig kode avvist; + gyldig kode fullfører byttet; SAMME kode kan ikke gjenbrukes; en helt ny + innlogging med den GAMLE adressen oppretter en fersk, tom konto (beviser + byttet er reelt og fullstendig). Ekte typesjekket produksjonsbuild kjørt + og bekreftet (ny `/verify-email`-rute listet). + **Rullet ut live 2026-07-20**, bruker bekreftet eksplisitt (spurte + samtidig og bekreftet at `teecup.teeoff.no/dashboard` nå er DEN samme + adressen for enhver innlogget bruker uansett rolle — nettopp poenget + med ADR-031/032): migrasjon 016 kjørt mot ekte `teecup_db` (bekreftet + nye kolonner + `email_change_token`-tabell finnes), deretter `docker + compose up -d --build teecup_api teecup_frontend`. Begge containere + boot-et rent, `/health`/`/dashboard`/`/account`/`/verify-email` → 200, + `teeoff.no` upåvirket. + Neste steg: -1. **Personlig landingsside + profil er BYGGET OG LIVE (ADR-031)** — se - over. Naturlig oppfølging: deltaker-tilgang (uten org-medlemskap) til - lag-chat/scorekort — bevisst utenfor omfang denne runden, se - FEATURE_BACKLOG.md. +1. **Personlig landingsside + profil (ADR-031) OG e-post/mobil (ADR-032) + er BEGGE LIVE.** Naturlig oppfølging: deltaker-tilgang (uten + org-medlemskap) til lag-chat/scorekort — bevisst utenfor omfang denne + runden, se FEATURE_BACKLOG.md. 2. **Punkt 2 fra samme 2026-07-20-runde (lavere prioritet enn #1):** midlertidige spillere + automatisk etter-runde-e-post — forslag klart i FEATURE_BACKLOG.md, tre åpne spørsmål, ingen kode skrevet. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 4c2debc..191d803 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -1058,6 +1058,27 @@ kjørt mot ekte `teecup_db`, begge containere redeployet, `/health`/ - "Mine runder" for RENE påmeldinger (`tournament_registration` uten roster ennå) — v1 viser kun rostrede lag. +### Oppfølging samme dag: e-post + mobil — ✅ BYGGET OG SCRATCH-VERIFISERT (ADR-032) + +Brukeren påpekte rett etter forrige runde at "identifikatoren" (e-post) +manglet i profilen, og at mobil (med landsnummer) burde være en opsjon. Full +detalj i ADR-032. + +| Del | Status | Notat | +|---|---|---| +| Mobil (landsnummer + nummer, to separate felt) | ✅ | Del av samme `PATCH /auth/profile` som resten av profilen — ren tilføyelse. | +| E-post — verifisert to-stegs bytte, IKKE en enkel PATCH | ✅ | Ny `email_change_token`-tabell (migrasjon 016, samme mønster som `magic_link_token`). `POST /auth/profile/email` (krever sesjon, sender lenke til den NYE adressen) + `POST /auth/profile/email/confirm` (forbruker token atomisk, ingen sesjon påkrevd — samme som selve magic-link-verifiseringen). E-posten endres ALDRI før lenken faktisk åpnes. Ny `/verify-email`-side. | + +**Scratch-verifisert, 10 sjekker** — inkl. at et bytte til en allerede brukt +adresse avvises, at e-posten forblir uendret helt til bekreftelse, at samme +kode ikke kan gjenbrukes, og at en ny innlogging med den GAMLE adressen +oppretter en fersk, tom konto (beviser byttet er reelt, ikke kosmetisk). +Ekte typesjekket produksjonsbuild kjørt og bekreftet. + +**Rullet ut live 2026-07-20**, bruker bekreftet eksplisitt: migrasjon 016 +kjørt mot ekte `teecup_db`, begge containere redeployet, `/health`/ +`/dashboard`/`/account`/`/verify-email` → 200, `teeoff.no` upåvirket. + --- ## Midlertidige spillere + automatisk etter-runde-invitasjon — 📋 FORESLÅTT 2026-07-20, IKKE bygget ennå diff --git a/app/email.py b/app/email.py index 8a6de21..65f30b5 100644 --- a/app/email.py +++ b/app/email.py @@ -145,3 +145,39 @@ async def send_organization_invitation_email( inviter=inviter_name, role=role_label, org=org_name, link=settings.PUBLIC_BASE_URL ) await to_thread(_send_sync, to_email, subject, body) + + +_EMAIL_CHANGE_TEMPLATES = { + "nb": { + "subject": "Bekreft ny e-postadresse for TeeCup", + "body": ( + "Hei,\n\n" + "Noen ba om å endre TeeCup-kontoens e-postadresse til denne adressen. " + "Åpne lenken under innen {minutes} minutter for å bekrefte:\n\n" + "{link}\n\n" + "Ba du ikke om dette selv, kan du se bort fra e-posten -- ingenting " + "endres uten at lenken åpnes.\n" + ), + }, + "en": { + "subject": "Confirm your new TeeCup email address", + "body": ( + "Hi,\n\n" + "Someone requested changing this TeeCup account's email address to this " + "address. Open the link below within {minutes} minutes to confirm:\n\n" + "{link}\n\n" + "If you didn't request this yourself, you can ignore this email -- " + "nothing changes unless the link is opened.\n" + ), + }, +} + + +async def send_email_change_confirmation(to_email: str, raw_token: str, locale: str = "nb") -> None: + """ADR-031-oppfølging: e-post er innloggings-identifikatoren, kan derfor + ikke endres med en enkel PATCH -- den NYE adressen må bevise eierskap + FØR selve kontoen endres. Samme token-i-lenke-mønster som magic-link.""" + template = _EMAIL_CHANGE_TEMPLATES.get(locale, _EMAIL_CHANGE_TEMPLATES["nb"]) + link = f"{settings.PUBLIC_BASE_URL}/verify-email?token={raw_token}" + body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, link=link) + await to_thread(_send_sync, to_email, template["subject"], body) diff --git a/app/routers/auth.py b/app/routers/auth.py index 1b165a1..a058615 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -65,8 +65,8 @@ from ..auth import ( ) from ..config import settings from ..db import org_connection, plain_connection -from ..email import send_magic_link_email, send_two_factor_code_email -from ..errors import app_error +from ..email import send_email_change_confirmation, send_magic_link_email, send_two_factor_code_email +from ..errors import app_error, translate_db_errors router = APIRouter(prefix="/auth", tags=["auth"]) @@ -589,6 +589,8 @@ class Me(BaseModel): gender: str | None handicap_index: float | None home_club: str | None + mobile_country_code: str | None + mobile_number: str | None avatar_url: str | None my_tournaments: list[MyTournament] @@ -616,7 +618,8 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: SELECT id::text AS id, email::text AS email, display_name, preferred_locale, (password_hash IS NOT NULL) AS has_password, two_factor_method, first_name, last_name, birth_date, gender, - handicap_index::float AS handicap_index, home_club, avatar_key + handicap_index::float AS handicap_index, home_club, + mobile_country_code, mobile_number, avatar_key FROM app_user WHERE id = $1 """, user.user_id, @@ -678,6 +681,8 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: gender=user_row["gender"], handicap_index=user_row["handicap_index"], home_club=user_row["home_club"], + mobile_country_code=user_row["mobile_country_code"], + mobile_number=user_row["mobile_number"], avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None, my_tournaments=my_tournaments, ) @@ -696,6 +701,11 @@ class ProfileUpdate(BaseModel): gender: str | None = Field(default=None, pattern="^[mfx]$") handicap_index: float | None = None home_club: str | None = Field(default=None, max_length=200) + # Landsnummer som eget felt (f.eks. "+47"), ikke sammensatt med selve + # nummeret -- lar frontend tilby en egen landsnummer-velger uten å + # måtte parse/splitte en fritekststreng i etterkant. + mobile_country_code: str | None = Field(default=None, max_length=5) + mobile_number: str | None = Field(default=None, max_length=30) @router.patch("/profile", response_model=Me) @@ -748,3 +758,87 @@ async def remove_avatar(user: CurrentUser = Depends(get_current_user)) -> Me: async with plain_connection() as conn: await conn.execute("UPDATE app_user SET avatar_key = NULL WHERE id = $1", user.user_id) return await me(user) + + +# --- Verifisert e-postbytte ------------------------------------------------- +# E-post er innloggings-identifikatoren (magic-link-mål) -- kan IKKE endres +# med en enkel PATCH slik de andre profilfeltene kan, uten å risikere at en +# skrivefeil eller en kapret sesjon stjeler kontoen for godt. To steg, samme +# token-hash-og-utløp-mønster som magic_link_token (004): (1) bevis at DU +# eier kontoen i dag (krever en gyldig sesjon), (2) bevis at du eier den NYE +# adressen (klikk lenken sendt DIT) -- ingen av de to alene er nok. + + +class EmailChangeRequest(BaseModel): + new_email: EmailStr + + +@router.post("/profile/email") +async def request_email_change( + body: EmailChangeRequest, + user: CurrentUser = Depends(get_current_user), +) -> dict: + new_email = body.new_email.lower() + async with plain_connection() as conn: + taken = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", new_email) + if taken is not None: + raise app_error(409, "DUPLICATE", "Denne e-postadressen er allerede i bruk av en annen konto.") + + raw_token = secrets.token_urlsafe(32) + expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES) + await conn.execute( + "INSERT INTO email_change_token (user_id, new_email, token_hash, expires_at) VALUES ($1, $2, $3, $4)", + user.user_id, + new_email, + _hash_secret(raw_token), + expires_at, + ) + + if settings.DEV_LOG_MAGIC_LINKS: + print(f"[DEV] E-postbytte-lenke for {new_email}: {raw_token}", flush=True) + elif settings.SMTP_CONFIGURED: + try: + await send_email_change_confirmation(new_email, raw_token) + except Exception: + traceback.print_exc() + return {"status": "ok"} + + +class EmailChangeConfirm(BaseModel): + token: str + + +@router.post("/profile/email/confirm", response_model=Me) +async def confirm_email_change(body: EmailChangeConfirm) -> Me: + token_hash = _hash_secret(body.token) + async with plain_connection() as conn: + # Atomisk forbruk, samme mønster som verify_magic_link. + token_row = await conn.fetchrow( + """ + UPDATE email_change_token + SET consumed_at = now() + WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now() + RETURNING user_id::text AS user_id, new_email::text AS new_email + """, + token_hash, + ) + if token_row is None: + raise app_error(401, "INVALID_EMAIL_CHANGE_LINK", "Lenken er ugyldig, brukt eller utløpt.") + + # Kan ha blitt tatt av en annen konto i tiden MELLOM forespørsel og + # bekreftelse -- sjekket på nytt her, ikke bare ved forespørselen. + taken = await conn.fetchval( + "SELECT id FROM app_user WHERE email = $1 AND id <> $2", + token_row["new_email"], + token_row["user_id"], + ) + if taken is not None: + raise app_error( + 409, "DUPLICATE", "Denne e-postadressen ble tatt i bruk av en annen konto i mellomtiden." + ) + + async with translate_db_errors(): + await conn.execute( + "UPDATE app_user SET email = $1 WHERE id = $2", token_row["new_email"], token_row["user_id"] + ) + return await me(CurrentUser(user_id=token_row["user_id"])) diff --git a/frontend/app/verify-email/page.tsx b/frontend/app/verify-email/page.tsx new file mode 100644 index 0000000..d36f7f4 --- /dev/null +++ b/frontend/app/verify-email/page.tsx @@ -0,0 +1,28 @@ +import { Suspense } from "react" +import { Wordmark } from "@/components/wordmark" +import { VerifyEmailForm } from "@/components/verify-email-form" + +export default function VerifyEmailPage() { + return ( +
+
+
+ +
+ + + + } + > + + +
+
+ ) +} diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx index 9299b68..72f98d9 100644 --- a/frontend/components/account-settings.tsx +++ b/frontend/components/account-settings.tsx @@ -8,7 +8,7 @@ import type React from "react" import { useEffect, useRef, useState } from "react" import Link from "next/link" -import { ArrowLeft, Camera, KeyRound, Lock, ShieldCheck, ShieldOff, User, X } from "lucide-react" +import { ArrowLeft, Camera, KeyRound, Lock, Mail, ShieldCheck, ShieldOff, User, X } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -28,6 +28,8 @@ type Me = { gender: "m" | "f" | "x" | null handicap_index: number | null home_club: string | null + mobile_country_code: string | null + mobile_number: string | null avatar_url: string | null } @@ -101,6 +103,8 @@ export function AccountSettings() {
+ +
@@ -163,6 +167,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) { const [gender, setGender] = useState(me.gender ?? "") const [hcp, setHcp] = useState(me.handicap_index === null ? "" : String(me.handicap_index)) const [homeClub, setHomeClub] = useState(me.home_club ?? "") + const [mobileCountryCode, setMobileCountryCode] = useState(me.mobile_country_code ?? "+47") + const [mobileNumber, setMobileNumber] = useState(me.mobile_number ?? "") const [saving, setSaving] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false) const [error, setError] = useState(null) @@ -186,6 +192,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) { gender: gender === "" ? null : gender, handicap_index: hcp.trim() === "" ? null : Number(hcp.replace(",", ".")), home_club: homeClub.trim() === "" ? null : homeClub.trim(), + mobile_country_code: mobileNumber.trim() === "" ? null : mobileCountryCode.trim(), + mobile_number: mobileNumber.trim() === "" ? null : mobileNumber.trim(), }), }) if (!res.ok) { @@ -360,6 +368,29 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) { className="h-11 rounded-xl" />
+
+ +
+ setMobileCountryCode(e.target.value)} + placeholder="+47" + className="h-11 w-20 shrink-0 rounded-xl text-center" + /> + setMobileNumber(e.target.value)} + placeholder="912 34 567" + className="h-11 flex-1 rounded-xl" + /> +
+
{error &&

{error}

} @@ -373,6 +404,109 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) { ) } +// --- E-post (identifikatoren) ----------------------------------------------- +// Bevisst IKKE en del av ProfileSection sin vanlige PATCH -- e-post er +// innloggings-identifikatoren, endring krever at den NYE adressen beviser +// eierskap først (se app/routers/auth.py sin request_email_change/ +// confirm_email_change). + +function EmailSection({ email }: { email: string }) { + const [editing, setEditing] = useState(false) + const [newEmail, setNewEmail] = useState("") + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [sent, setSent] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setSubmitting(true) + setError(null) + try { + const res = await fetch("/auth/profile/email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ new_email: newEmail.trim() }), + }) + if (!res.ok) { + const body = await res.json().catch(() => null) + throw new Error(body?.detail?.message ?? "Klarte ikke å sende bekreftelseslenken.") + } + setSent(true) + } catch (err) { + setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.") + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+
+

E-post

+
+ +

+ Dette er identifikatoren du logger inn med: {email} +

+ + {sent ? ( +

+ Sjekk innboksen til {newEmail.trim()} — åpne lenken der for å fullføre byttet. Adressen + endres ikke før den er bekreftet. +

+ ) : editing ? ( +
+
+ + setNewEmail(e.target.value)} + className="h-12 rounded-xl" + /> +
+
+ + +
+
+ ) : ( + + )} + + {error &&

{error}

} +
+ ) +} + function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) { const [password, setPassword] = useState("") const [submitting, setSubmitting] = useState(false) diff --git a/frontend/components/verify-email-form.tsx b/frontend/components/verify-email-form.tsx new file mode 100644 index 0000000..e6a84da --- /dev/null +++ b/frontend/components/verify-email-form.tsx @@ -0,0 +1,100 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { useSearchParams } from "next/navigation" +import { CheckCircle2, XCircle } from "lucide-react" + +type Status = "verifying" | "success" | "error" + +export function VerifyEmailForm() { + const searchParams = useSearchParams() + const token = searchParams.get("token") + const [status, setStatus] = useState(token ? "verifying" : "error") + const [email, setEmail] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + if (!token) { + setError("Mangler kode i lenken.") + return + } + async function verify() { + try { + const res = await fetch("/auth/profile/email/confirm", { + 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 result: { email: string } = await res.json() + setEmail(result.email) + setStatus("success") + } catch (err) { + setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.") + setStatus("error") + } + } + void verify() + }, [token]) + + if (status === "verifying") { + return ( +
+ + ) + } + + if (status === "success") { + return ( +
+
+
+
+

E-post oppdatert

+ {email && ( +

+ Du logger nå inn med {email}. +

+ )} +
+ + Til kontoinnstillinger + +
+ ) + } + + return ( +
+
+
+
+

Kunne ikke bekrefte

+

{error}

+
+ + Til kontoinnstillinger + +
+ ) +}