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:
parent
b042a18a45
commit
45850a97fa
10 changed files with 554 additions and 9 deletions
|
|
@ -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",
|
||||
|
|
|
|||
33
016_profile_contact.sql
Normal file
33
016_profile_contact.sql
Normal 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;
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
52
CLAUDE.md
52
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.
|
||||
|
|
|
|||
|
|
@ -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å
|
||||
|
|
|
|||
36
app/email.py
36
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)
|
||||
|
|
|
|||
|
|
@ -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"]))
|
||||
|
|
|
|||
28
frontend/app/verify-email/page.tsx
Normal file
28
frontend/app/verify-email/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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() {
|
|||
<div className="flex flex-col gap-6">
|
||||
<ProfileSection me={me} onChanged={loadMe} />
|
||||
|
||||
<EmailSection email={me.email} />
|
||||
|
||||
<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">
|
||||
|
|
@ -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<string | null>(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"
|
||||
/>
|
||||
</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>
|
||||
|
||||
{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 }) {
|
||||
const [password, setPassword] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
|
|
|||
100
frontend/components/verify-email-form.tsx
Normal file
100
frontend/components/verify-email-form.tsx
Normal 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 nå 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue