Live og dokumentert. /dashboard er nå den ene, felles adressen for enhver innlogget bruker — organisator, spiller, eller begge — med personlig profil (inkl. e-post og mobil) redigerbar på /account.

This commit is contained in:
Erol Haagenrud 2026-07-20 11:32:10 +02:00
parent b042a18a45
commit 45850a97fa
10 changed files with 554 additions and 9 deletions

View file

@ -329,7 +329,9 @@
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" https://teeoff.no)", "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_player_edit_and_dates.py)",
"Bash(python3 /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/test_profile_and_myrounds.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": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

33
016_profile_contact.sql Normal file
View file

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

View file

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

View file

@ -2023,11 +2023,55 @@ Ferdig og verifisert:
teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`/ teecup_frontend`. Begge containere boot-et rent, `/health`/`/dashboard`/
`/account` → 200, `teeoff.no` upåvirket. `/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: Neste steg:
1. **Personlig landingsside + profil er BYGGET OG LIVE (ADR-031)** — se 1. **Personlig landingsside + profil (ADR-031) OG e-post/mobil (ADR-032)
over. Naturlig oppfølging: deltaker-tilgang (uten org-medlemskap) til er BEGGE LIVE.** Naturlig oppfølging: deltaker-tilgang (uten
lag-chat/scorekort — bevisst utenfor omfang denne runden, se org-medlemskap) til lag-chat/scorekort — bevisst utenfor omfang denne
FEATURE_BACKLOG.md. runden, se FEATURE_BACKLOG.md.
2. **Punkt 2 fra samme 2026-07-20-runde (lavere prioritet enn #1):** 2. **Punkt 2 fra samme 2026-07-20-runde (lavere prioritet enn #1):**
midlertidige spillere + automatisk etter-runde-e-post — forslag klart midlertidige spillere + automatisk etter-runde-e-post — forslag klart
i FEATURE_BACKLOG.md, tre åpne spørsmål, ingen kode skrevet. i FEATURE_BACKLOG.md, tre åpne spørsmål, ingen kode skrevet.

View file

@ -1058,6 +1058,27 @@ kjørt mot ekte `teecup_db`, begge containere redeployet, `/health`/
- "Mine runder" for RENE påmeldinger (`tournament_registration` uten - "Mine runder" for RENE påmeldinger (`tournament_registration` uten
roster ennå) — v1 viser kun rostrede lag. 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å ## Midlertidige spillere + automatisk etter-runde-invitasjon — 📋 FORESLÅTT 2026-07-20, IKKE bygget ennå

View file

@ -145,3 +145,39 @@ async def send_organization_invitation_email(
inviter=inviter_name, role=role_label, org=org_name, link=settings.PUBLIC_BASE_URL inviter=inviter_name, role=role_label, org=org_name, link=settings.PUBLIC_BASE_URL
) )
await to_thread(_send_sync, to_email, subject, body) 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 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)

View file

@ -65,8 +65,8 @@ from ..auth import (
) )
from ..config import settings from ..config import settings
from ..db import org_connection, plain_connection from ..db import org_connection, plain_connection
from ..email import send_magic_link_email, send_two_factor_code_email from ..email import send_email_change_confirmation, send_magic_link_email, send_two_factor_code_email
from ..errors import app_error from ..errors import app_error, translate_db_errors
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@ -589,6 +589,8 @@ class Me(BaseModel):
gender: str | None gender: str | None
handicap_index: float | None handicap_index: float | None
home_club: str | None home_club: str | None
mobile_country_code: str | None
mobile_number: str | None
avatar_url: str | None avatar_url: str | None
my_tournaments: list[MyTournament] 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, SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
(password_hash IS NOT NULL) AS has_password, two_factor_method, (password_hash IS NOT NULL) AS has_password, two_factor_method,
first_name, last_name, birth_date, gender, 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 FROM app_user WHERE id = $1
""", """,
user.user_id, user.user_id,
@ -678,6 +681,8 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
gender=user_row["gender"], gender=user_row["gender"],
handicap_index=user_row["handicap_index"], handicap_index=user_row["handicap_index"],
home_club=user_row["home_club"], 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, avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
my_tournaments=my_tournaments, my_tournaments=my_tournaments,
) )
@ -696,6 +701,11 @@ class ProfileUpdate(BaseModel):
gender: str | None = Field(default=None, pattern="^[mfx]$") gender: str | None = Field(default=None, pattern="^[mfx]$")
handicap_index: float | None = None handicap_index: float | None = None
home_club: str | None = Field(default=None, max_length=200) 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) @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: async with plain_connection() as conn:
await conn.execute("UPDATE app_user SET avatar_key = NULL WHERE id = $1", user.user_id) await conn.execute("UPDATE app_user SET avatar_key = NULL WHERE id = $1", user.user_id)
return await me(user) 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"]))

View file

@ -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 (
<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>
}
>
<VerifyEmailForm />
</Suspense>
</div>
</main>
)
}

View file

@ -8,7 +8,7 @@
import type React from "react" import type React from "react"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import Link from "next/link" 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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@ -28,6 +28,8 @@ type Me = {
gender: "m" | "f" | "x" | null gender: "m" | "f" | "x" | null
handicap_index: number | null handicap_index: number | null
home_club: string | null home_club: string | null
mobile_country_code: string | null
mobile_number: string | null
avatar_url: string | null avatar_url: string | null
} }
@ -101,6 +103,8 @@ export function AccountSettings() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<ProfileSection me={me} onChanged={loadMe} /> <ProfileSection me={me} onChanged={loadMe} />
<EmailSection email={me.email} />
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} /> <PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"> <section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
@ -163,6 +167,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
const [gender, setGender] = useState(me.gender ?? "") const [gender, setGender] = useState(me.gender ?? "")
const [hcp, setHcp] = useState(me.handicap_index === null ? "" : String(me.handicap_index)) const [hcp, setHcp] = useState(me.handicap_index === null ? "" : String(me.handicap_index))
const [homeClub, setHomeClub] = useState(me.home_club ?? "") 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 [saving, setSaving] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -186,6 +192,8 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
gender: gender === "" ? null : gender, gender: gender === "" ? null : gender,
handicap_index: hcp.trim() === "" ? null : Number(hcp.replace(",", ".")), handicap_index: hcp.trim() === "" ? null : Number(hcp.replace(",", ".")),
home_club: homeClub.trim() === "" ? null : homeClub.trim(), 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) { if (!res.ok) {
@ -360,6 +368,29 @@ function ProfileSection({ me, onChanged }: { me: Me; onChanged: () => void }) {
className="h-11 rounded-xl" className="h-11 rounded-xl"
/> />
</div> </div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Label htmlFor="mobile-number" className="text-sm font-semibold">
Mobil
</Label>
<div className="flex gap-2">
<Input
id="mobile-country-code"
aria-label="Landsnummer"
value={mobileCountryCode}
onChange={(e) => setMobileCountryCode(e.target.value)}
placeholder="+47"
className="h-11 w-20 shrink-0 rounded-xl text-center"
/>
<Input
id="mobile-number"
type="tel"
value={mobileNumber}
onChange={(e) => setMobileNumber(e.target.value)}
placeholder="912 34 567"
className="h-11 flex-1 rounded-xl"
/>
</div>
</div>
</div> </div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>} {error && <p className="text-sm font-medium text-destructive">{error}</p>}
@ -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<string | null>(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 (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<Mail aria-hidden="true" className="size-5 text-primary" />
</div>
<h2 className="text-base font-bold text-foreground">E-post</h2>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Dette er identifikatoren du logger inn med: <span className="font-semibold text-foreground">{email}</span>
</p>
{sent ? (
<p className="text-sm font-medium text-primary text-pretty">
Sjekk innboksen til {newEmail.trim()} åpne lenken der for å fullføre byttet. Adressen
endres ikke før den er bekreftet.
</p>
) : editing ? (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="new-email" className="text-sm font-semibold">
Ny e-postadresse
</Label>
<Input
id="new-email"
type="email"
autoFocus
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
className="h-12 rounded-xl"
/>
</div>
<div className="flex gap-2">
<Button
type="submit"
disabled={submitting || newEmail.trim() === ""}
className="h-12 shrink-0 rounded-xl font-semibold"
>
{submitting ? "Sender …" : "Send bekreftelse"}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => setEditing(false)}
className="h-12 shrink-0 rounded-xl font-semibold"
>
Avbryt
</Button>
</div>
</form>
) : (
<Button
type="button"
variant="outline"
onClick={() => setEditing(true)}
className="h-11 w-fit rounded-xl font-semibold"
>
Endre e-post
</Button>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</section>
)
}
function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) { function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) {
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)

View file

@ -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<Status>(token ? "verifying" : "error")
const [email, setEmail] = useState<string | null>(null)
const [error, setError] = useState<string | null>(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 (
<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">
Bekrefter ny e-postadresse
</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">E-post oppdatert</h2>
{email && (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Du logger inn med <span className="font-semibold text-foreground">{email}</span>.
</p>
)}
</div>
<Link
href="/account"
className="text-sm font-semibold text-primary transition-colors hover:text-primary/80"
>
Til kontoinnstillinger
</Link>
</div>
)
}
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-destructive/15">
<XCircle aria-hidden="true" className="size-8 text-destructive" />
</div>
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-extrabold tracking-tight text-balance">Kunne ikke bekrefte</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{error}</p>
</div>
<Link
href="/account"
className="text-sm font-semibold text-primary transition-colors hover:text-primary/80"
>
Til kontoinnstillinger
</Link>
</div>
)
}