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 (
+
{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+ 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 ? ( + + ) : ( + + )} + + {error &&{error}
} ++ Bekrefter ny e-postadresse … +
++ Du logger nå inn med {email}. +
+ )} +{error}
+