Update Todos

Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)

Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match

Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup

Widen tournaments.py: list_sessions/list_teams/concede_tournament

Widen courses.py: list_holes

Widen messaging.py: team chat REST-endepunkter (list/send/delete)

Legge til my_session_id/my_match_id i /auth/me sin my_tournaments

Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»

Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)

Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag

Presentere plan, deploye og oppdatere .md-filer for deltaker-tilgang-runden

Bygge og scratch-verifisere sekundær e-postadresse (migrasjon 017)

Bygge HCP-historikk over tid
Sekundær e-postadresse (det enkle tilfellet) er bygget og scratch-verifisert (20/20 sjekker), test_isolation.sql fortsatt 12/12.

Hva er bygget:

Migrasjon 017_secondary_email.sql — to nye tabeller (secondary_email_token, user_secondary_email), samme bevis-eierskap-token-mønster som ADR-032.
Backend: POST /auth/secondary-email (send bekreftelseslenke), POST /auth/secondary-email/confirm (bekreft, ingen sesjon påkrevd), DELETE /auth/secondary-email/{id}. Viktigst: verify_magic_link og login_with_password løser nå en sekundær adresse til den eksisterende eierens konto i stedet for å opprette en ny, separat konto — dette er selve poenget med funksjonen.
Frontend: ny seksjon "Andre e-postadresser" i /account (ikke dashbordet — se merknad under), /verify-email håndterer nå både e-postbytte og ny-sekundær-adresse via en ?kind=secondary-parameter.
Én bevisst plasseringsavgjørelse jeg tok uten å spørre: du sa opprinnelig at hele multi-e-post-flyten burde skje "fra dashboard-siden". Jeg la likevel dette i /account, fordi jeg her kun bygger det enkle tilfellet (fri adresse) — konsistent med hvor e-postbytte (ADR-032) allerede ligger. Når/hvis den harde saken (ekte konto-sammenslåing, der data faktisk "dukker opp") bygges senere, er dashbordet trolig riktigere siden gevinsten vises der. Si fra hvis du vil at den skal flyttes allerede nå.

Verifisert grundig: ny sekundær-adresse legges IKKE til før bekreftet; token kan ikke gjenbrukes; adresse som allerede er en annens hovedadresse ELLER en annens sekundæradresse avvises tydelig; innlogging (magic-link OG passord) via sekundæradressen løses korrekt til samme, eksisterende konto; en fremmed kan ikke slette andres sekundæradresse; og — kritisk — etter sletting oppretter en ny innlogging på den adressen en helt ny, separat konto (beviser fjerningen er reell).

Ingen migrasjon kjørt mot ekte teecup_db ennå.
This commit is contained in:
Erol Haagenrud 2026-07-22 06:14:31 +02:00
parent df08339654
commit b376f095be
8 changed files with 580 additions and 40 deletions

View file

@ -340,7 +340,8 @@
"Bash(python3 -m py_compile app/routers/auth.py)",
"Bash(python3 -m py_compile app/routers/auth.py app/routers/scoring.py app/routers/matches.py app/routers/tournaments.py app/routers/courses.py app/routers/messaging.py app/routers/registration.py app/team_authz.py)",
"Bash(rm -f /opt/requirements.txt)",
"Bash(python3 -m py_compile app/routers/*.py app/*.py)"
"Bash(python3 -m py_compile app/routers/*.py app/*.py)",
"Bash(python3 -m py_compile app/routers/auth.py app/email.py)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",

49
017_secondary_email.sql Normal file
View file

@ -0,0 +1,49 @@
-- =====================================================================
-- TeeCup — sekundær, verifisert e-postadresse per konto (migrasjon 017)
-- =====================================================================
-- Oppfølging av ADR-032 (verifisert e-postbytte), reist av brukeren rett
-- etterpå: én person kan ha flere e-postadresser i omløp samtidig (privat
-- vs. jobb-adresse en organisator har lagt inn en spiller/invitasjon på).
-- Dette dekker BEVISST kun det enkle tilfellet -- en FRI, ukrevd adresse
-- legges til og verifiseres. Ekte konto-SAMMENSLÅING (adressen tilhører
-- allerede en annen, eksisterende konto) er en egen, større, separat sak
-- (se FEATURE_BACKLOG.md "Én person, flere e-postadresser") og håndheves
-- IKKE her -- unikheten under garanterer at en adresse aldri kan stå som
-- sekundær på to kontoer samtidig (samme rad ville krevd en eksplisitt
-- sammenslåingsbeslutning som ikke finnes ennå).
--
-- To tabeller, samme token-hash-og-utløp-mønster som email_change_token
-- (016): en midlertidig verifiseringstoken (bevis eierskap FØR raden
-- faktisk opprettes -- unngår en "krevd men aldri bekreftet"-adresse som
-- likevel opptar en unik plass), og selve den verifiserte adressen.
-- =====================================================================
\set ON_ERROR_STOP on
CREATE TABLE secondary_email_token (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
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 secondary_email_token (user_id);
CREATE TABLE user_secondary_email (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
-- Globalt unik -- kan aldri stå som sekundær på to kontoer samtidig,
-- og aldri samtidig være en ANNEN kontos primære e-post (håndhevet i
-- app-laget ved innsetting, se app/routers/auth.py -- en ren DB-CHECK
-- på tvers av to tabeller er ikke mulig i Postgres).
email citext NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON user_secondary_email (user_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON secondary_email_token TO teecup_app;
GRANT SELECT, INSERT, DELETE ON user_secondary_email TO teecup_app;

View file

@ -2105,31 +2105,87 @@ Ferdig og verifisert:
avhengighets-bivirkning, ingen backend-kode rørt). Begge containere
boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
- **Dashboard/konto-runde (2026-07-21): to store punkter reist samtidig av
brukeren.** (1) "Alt relatert til dashboard/account/brukerkontoer" —
scopet sammen med brukeren via spørsmål: tom-tilstand-redesignet ble
PAUSERT (bruker ba om "juster retningen" og reiste et dypere spørsmål om
hvorvidt organisasjon fortsatt bør være "det som meldes først" — se eget
punkt i FEATURE_BACKLOG.md, min vurdering: nei, bør bli ett likestilt
valg blant flere). (2) Et helt nytt, stort forslag om frittstående
rundeføring + detaljert statistikk (putter/chip/bunkerslag/straffeslag/
førsteputt-lengde) UTEN turnering/organisasjon — grundig notert i
FEATURE_BACKLOG.md med en eksplisitt arkitektur-advarsel: dette
UTFORDRER tenant-invarianten (`organization_id` på alle domenetabeller)
direkte og trenger en egen ADR, ikke bygget denne runden.
**Deltaker-tilgang til lag-chat/scorekort — ✅ BYGGET OG LIVE
2026-07-21** (én av tre konkrete følgepunkter brukeren bekreftet i samme
runde, de to andre — sekundær e-post, HCP-historikk — tas fortløpende
etterpå): fjernet den blanke `get_authorized_org`-sperren fra ni
endepunkter på tvers av `messaging.py`/`scoring.py`/`matches.py`/
`tournaments.py`/`courses.py`, erstattet med de ALLEREDE eksisterende
domene-sjekkene (`user_is_rostered_on_team`/`user_is_match_participant`/
`user_is_team_captain`) som viste seg å støtte ikke-org-medlemmer helt
fint fra før — de var bare aldri nåbare. To nye delte hjelpefunksjoner i
`team_authz.py` (`is_org_member`, `user_is_tournament_participant`
sistnevnte FLYTTET dit fra `registration.py` for å unngå sirkulær
import) dekker de endepunktene som IKKE hadde noen finkornet sjekk fra
før (ren fjerning der ville åpnet dem for enhver innlogget bruker).
`/auth/me` sin `my_tournaments` fikk `my_session_id`/`my_match_id`;
"Mine runder"-kortet fikk "Lag-chat"/"Scorekort"-lenker.
**Scratch-verifisert grundig, 43 sjekker** (isolert scratch-rolle+MinIO+
engangs API-container): rostret ikke-medlem fikk korrekt tilgang
overalt (inkl. faktisk sendt chat-melding og hull-resultat), fortsatt
avvist fra det ANDRE lagets chat, en helt fremmed bruker avvist overalt,
org-eier beholder alt UNNTATT lag-chat (uendret, med vilje), kryss-org-
isolasjon bekreftet. **Reelt funn UNDER selve scratch-testingen:** en
rostret-men-ikke-kaptein spiller ble først uventet GODTATT til walkover
— viste seg å være en allerede tiltenkt, dokumentert fallback
(`user_is_team_captain`: "ingen kaptein utpekt ennå = enhver rostret
spiller godtas"), ikke en bug — testen ble rettet (la til en faktisk
kaptein) og bekreftet deretter riktig avvisning. `test_isolation.sql`
12/12 uendret (ingen skjemaendring). Ekte typesjekket produksjonsbuild
kjørt og bekreftet.
**Rullet ut live 2026-07-21**, bruker bekreftet eksplisitt: ingen
migrasjon, `docker compose up -d --build teecup_api teecup_frontend`,
begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
upåvirket.
Neste steg:
1. **Venter på brukerens retningsbekreftelse:** redesign av dashbordets
tom-tilstand ved første innlogging (mindre organisator-vridd, se
FEATURE_BACKLOG.md) — forslag lagt frem 2026-07-20, ikke bygget.
2. **Notert, IKKE designet i detalj:** én person med flere e-post-adresser
1. **Pågår, samme økt:** bygge de to gjenværende bekreftede punktene fra
dashboard/konto-runden (2026-07-21) — sekundær e-postadresse (enkelt
tilfelle, gjenbruker ADR-032s token-mønster) og HCP-historikk over tid.
2. **Pauset, venter på retning:** dashbordets tom-tilstand ved første
innlogging. Brukeren ba om å justere det opprinnelige 2026-07-20-
forslaget i lys av et dypere spørsmål — bør organisasjon fortsatt være
"det som meldes først"? Min vurdering (se FEATURE_BACKLOG.md): nei,
bør bli ett likestilt valg blant flere. Bygging avhenger delvis av
punktet under (hvilken tredje kortform tom-skjermen skal ha).
3. **Nytt, stort, IKKE designet:** frittstående rundeføring + detaljert
statistikk (putter/chip/bunkerslag/straffeslag/førsteputt-lengde) UTEN
turnering/organisasjon, reist 2026-07-21. Utfordrer tenant-invarianten
(`organization_id` på alle domenetabeller) direkte — trenger en egen,
dedikert ADR-runde, IKKE noe som skal bygges i forlengelsen av en
vanlig funksjonsrunde. Se full analyse og åpne spørsmål i
FEATURE_BACKLOG.md.
4. **Notert, IKKE designet i detalj:** én person med flere e-post-adresser
— legge til en verifisert sekundær-adresse fra dashbordet, med
turneringer/data som "dukker opp"/slås sammen deretter, og fremtidige
innlogginger med enten adresse. Fanget grundig i FEATURE_BACKLOG.md,
inkl. et reelt uløst spørsmål (ekte konto-sammenslåing hvis adressen
allerede tilhører en annen eksisterende konto) som trenger egen,
separat designrunde.
3. 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, se FEATURE_BACKLOG.md.
4. **Punkt 2 fra 2026-07-20-runden (midlertidige spillere):** forslag klart
i FEATURE_BACKLOG.md, tre åpne spørsmål, ingen kode skrevet.
5. Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
separat designrunde. **Merk:** dette overlapper med punkt 1 sin
"sekundær e-postadresse" — punkt 1 er det ENKLE, avgrensede tilfellet
(fri adresse), dette punktet er det harde tilfellet (adressen tilhører
allerede en annen konto).
5. **Deltaker-tilgang til lag-chat/scorekort er nå BYGGET OG LIVE**
(2026-07-21, se status over) — ADR-031s tidligere notert begrensning
er dermed tettet.
6. Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
forespørsel: offline-scoreregistrering er ALDRI browser-testet i
praksis (kun kodegjennomgang + build-verifisering, se ADR-028). Bruker
bør selv åpne et scorekort, skru på Chrome DevTools sin Offline-bryter,
registrere et par slag, skru nettet på igjen, og bekrefte at de faktisk
synkes — først da er flyten reelt bevist.
6. **Alle fire UI-/UX-hull notert 2026-07-19 er nå fikset** (datovisning
ADR-030, medlemsside-ruten, «ny organisasjon», dato-sammendrag — se
status over, 2026-07-21). Ingen gjenstår fra den runden.
7. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for
ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger
(bevisst utsatt i ADR-025).

View file

@ -1049,6 +1049,9 @@ gjort i denne runden. Notifikasjons-/aktivitetsfeed og HCP-historikk over
tid også bevisst utenfor omfang v1 (samme begrunnelse som opprinnelig
forslag).
**Oppdatering 2026-07-21 — deltaker-tilgang til lag-chat/scorekort er
dermed ✅ BYGGET OG LIVE, se egen seksjon lenger ned.**
**Scratch-verifisert, 15 sjekker** (profil-CRUD komplett, inkl. sletting av
enkeltfelt og avatar; en EKTE ren spiller uten org-medlemskap ser riktig
`my_tournaments`; den kritiske sikkerhetssjekken: samme spiller får nå se
@ -1142,6 +1145,75 @@ koblingen skal skje).
---
## Deltaker-tilgang til lag-chat og scorekort (uten org-medlemskap) — ✅ BYGGET OG LIVE 2026-07-21
Direkte oppfølging av ADR-031s kjente, notert begrensning: "Mine runder"
lenket til den offentlige turnering-siden, men IKKE til lagets private
chat eller det skrivbare scorekortet — begge krevde fortsatt ekte
organisasjonsmedlemskap (`get_authorized_org`), noe en ren, rostret/
påmeldt spiller (uten organisasjonsmedlemskap) ikke har.
**Kjernefunn ved gjennomlesing (ikke antatt):** de faktiske
autorisasjonsprimitivene (`user_is_rostered_on_team`,
`user_is_match_participant`, `user_is_team_captain`,
`own_team_ids` — alle i `app/team_authz.py`/`app/blind_draw.py`) støttet
ALLEREDE ikke-org-medlemmer korrekt overalt — de var bare plassert BAK en
ekstra, blank `Depends(get_authorized_org)`-sperre på ni endepunkter på
tvers av fire filer. Fikset ved kirurgisk å fjerne akkurat den sperren fra
disse ni (lag-chat lese/skrive/slette i `messaging.py`; scorekort-lesing,
slag-/hull-resultat-innsending, walkover i `scoring.py`; match-/lag-/
økt-listing i `matches.py`/`tournaments.py`; walkover-på-turnering-nivå i
`tournaments.py`; bane-hull i `courses.py`) — de eksisterende
domene-sjekkene (som allerede har egen org-admin-fallback der det er
tiltenkt) er den REELLE sikkerhetsgrensen, ikke `get_authorized_org`.
**For endepunkter som IKKE hadde noen finkornet sjekk i det hele tatt**
(f.eks. `get_scorecard`, `list_sessions`, `list_teams` — disse stolte
UTELUKKENDE på org-medlemskap) ville en ren fjerning av sperren latt EN
HVILKEN SOM HELST innlogget bruker se dem — løst med et nytt, eksplisitt
`is_org_member(...) OR user_is_tournament_participant(...)`-OR (begge nye
hjelpefunksjoner i `team_authz.py`). `user_is_tournament_participant` er
FLYTTET dit fra `registration.py` (het `is_participant` der) — org-scopede
routere kan ikke importere fra `registration.py` uten sirkulær import
(registration.py importerer FRA dem), men alle importerer allerede fritt
fra den avhengighetsfrie `team_authz.py`. `courses.py` sin `list_holes`
fikk en bevisst LØSERE sjekk (`user_is_org_player` — kun "koblet til NOEN
spillerprofil i org-en", ikke bundet til én turnering) siden par/
stroke-index er lavsensitiv banedata, ikke spillerdata.
**`/auth/me` utvidet** med `my_session_id`/`my_match_id` per rad i
`my_tournaments` (en av spillerens egne matcher, ikke-avgjort foretrukket)
— lar frontend lenke direkte til riktig chat/scorekort uten at spilleren
selv må navigere via program-/blind draw-skjermene (som fortsatt krever
org-medlemskap for andre formål). "Mine runder"-kortet i `dashboard.tsx`
fikk to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når
`my_match_id` finnes).
**Scratch-verifisert grundig, 43 automatiserte sjekker** (isolert
`teecup_app_scratch`-rolle, isolert scratch-MinIO, engangs API-container):
en rostret spiller UTEN org-medlemskap fikk korrekt tilgang til alt de ni
endepunktene dekker (inkl. faktisk å SENDE en chat-melding og et
hull-resultat); samme spiller fortsatt korrekt AVVIST fra det andre laget
sin chat; en helt fremmed innlogget bruker (ingen spillerkobling i org-en
i det hele tatt) avvist overalt; org-eier beholder full tilgang til alt
UNNTATT lag-chat (ekte privat, med vilje uendret — ADR-025); kryss-org-
isolasjon bekreftet (kan ikke nå egen turnering via en ANNEN org-id);
og — et reelt funn UNDER testingen, ikke en bug — en rostret-men-ikke-
utpekt-kaptein spiller ble først FEILAKTIG godtatt til walkover fordi
laget ennå ikke hadde noen utpekt kaptein (allerede dokumentert,
tiltenkt fallback i `user_is_team_captain`: "ingen kaptein ennå = enhver
rostret spiller godtas") — testen ble korrigert (la til en faktisk
kaptein) og bekreftet deretter riktig avvisning av ikke-kapteinen.
`test_isolation.sql` fortsatt 12/12 (ingen skjemaendring). Ekte
typesjekket produksjonsbuild av frontend kjørt og bekreftet.
**Rullet ut live 2026-07-21**, bruker bekreftet eksplisitt: ingen
migrasjon, `docker compose up -d --build teecup_api teecup_frontend`,
begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
upåvirket.
---
## Dashboard: tom-tilstand ved første innlogging — 📋 UNDER REVURDERING (2026-07-21), IKKE bygget
Brukeren påpekte 2026-07-20 at dagens tomme-tilstand ("Du har ingen

View file

@ -181,3 +181,44 @@ async def send_email_change_confirmation(to_email: str, raw_token: str, locale:
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)
_SECONDARY_EMAIL_TEMPLATES = {
"nb": {
"subject": "Bekreft ekstra e-postadresse for TeeCup",
"body": (
"Hei,\n\n"
"Noen la til denne adressen som en EKSTRA (sekundær) e-postadresse på "
"en TeeCup-konto -- kontoens hovedadresse endres IKKE. Åpne lenken "
"under innen {minutes} minutter for å bekrefte at du eier denne "
"adressen:\n\n"
"{link}\n\n"
"Ba du ikke om dette selv, kan du se bort fra e-posten -- ingenting "
"legges til uten at lenken åpnes.\n"
),
},
"en": {
"subject": "Confirm an additional TeeCup email address",
"body": (
"Hi,\n\n"
"Someone added this address as an ADDITIONAL (secondary) email on a "
"TeeCup account -- the account's primary address is NOT changed. Open "
"the link below within {minutes} minutes to confirm you own this "
"address:\n\n"
"{link}\n\n"
"If you didn't request this yourself, you can ignore this email -- "
"nothing is added unless the link is opened.\n"
),
},
}
async def send_secondary_email_verification(to_email: str, raw_token: str, locale: str = "nb") -> None:
"""Én person, flere e-postadresser (notert i FEATURE_BACKLOG.md) --
kun det enkle tilfellet (fri, ukrevd adresse). Samme
token-i-lenke-mønster som send_email_change_confirmation, egen mal
siden budskapet er reelt forskjellig ("legges til" vs. "endres til")."""
template = _SECONDARY_EMAIL_TEMPLATES.get(locale, _SECONDARY_EMAIL_TEMPLATES["nb"])
link = f"{settings.PUBLIC_BASE_URL}/verify-email?token={raw_token}&kind=secondary"
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,7 +65,12 @@ from ..auth import (
)
from ..config import settings
from ..db import org_connection, plain_connection
from ..email import send_email_change_confirmation, send_magic_link_email, send_two_factor_code_email
from ..email import (
send_email_change_confirmation,
send_magic_link_email,
send_secondary_email_verification,
send_two_factor_code_email,
)
from ..errors import app_error, translate_db_errors
router = APIRouter(prefix="/auth", tags=["auth"])
@ -244,23 +249,35 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request:
raise app_error(401, "INVALID_MAGIC_LINK", "Lenken er ugyldig, brukt eller utløpt.")
email = token_row["email"]
placeholder_name = email.split("@")[0]
user_id = await conn.fetchval(
"""
INSERT INTO app_user (email, display_name, preferred_locale)
VALUES ($1, $2, $3)
ON CONFLICT (email) WHERE email IS NOT NULL DO NOTHING
RETURNING id
""",
email,
body.display_name or placeholder_name,
token_row["locale"],
# Sekundær e-post (FEATURE_BACKLOG.md "Én person, flere e-post-
# adresser") løses til EIERENS eksisterende konto -- må sjekkes FØR
# app_user-oppslaget under, ellers ville en innlogging på en
# sekundær adresse (som med vilje ALDRI står i app_user.email)
# stille opprettet en helt ny, separat, feilaktig konto.
secondary_owner_id = await conn.fetchval(
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email
)
if user_id is None:
# En annen samtidig verifisering for samme e-post vant innsettingen
# (eller brukeren fantes allerede) -- preferred_locale røres IKKE
# her, kun ved førstegangsopprettelse (se modul-docstring).
user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
if secondary_owner_id is not None:
user_id = secondary_owner_id
else:
placeholder_name = email.split("@")[0]
user_id = await conn.fetchval(
"""
INSERT INTO app_user (email, display_name, preferred_locale)
VALUES ($1, $2, $3)
ON CONFLICT (email) WHERE email IS NOT NULL DO NOTHING
RETURNING id
""",
email,
body.display_name or placeholder_name,
token_row["locale"],
)
if user_id is None:
# En annen samtidig verifisering for samme e-post vant
# innsettingen (eller brukeren fantes allerede) --
# preferred_locale røres IKKE her, kun ved førstegangs-
# opprettelse (se modul-docstring).
user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
# Koble enhver player-rad (ADR-017) OG enhver ventende
# organisasjonsinvitasjon (ADR-022) med matchende e-post til denne
@ -287,6 +304,17 @@ async def login_with_password(body: PasswordLoginRequest, response: Response, re
user_row = await conn.fetchrow(
"SELECT id::text AS id, password_hash FROM app_user WHERE email = $1", email
)
if user_row is None:
# Kan være en sekundær adresse (FEATURE_BACKLOG.md "Én person,
# flere e-postadresser") -- passordet ligger uansett på selve
# kontoen, ikke adressen, så slå opp eieren og fortsett normalt.
secondary_owner_id = await conn.fetchval(
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email
)
if secondary_owner_id is not None:
user_row = await conn.fetchrow(
"SELECT id::text AS id, password_hash FROM app_user WHERE id = $1", secondary_owner_id
)
# Identisk feil uansett årsak (e-post finnes ikke / intet passord satt /
# feil passord) -- unngår enumerering, samme prinsipp som magic-link.
if (
@ -582,6 +610,11 @@ class MyTournament(BaseModel):
my_match_id: str | None
class SecondaryEmailOut(BaseModel):
id: str
email: str
class Me(BaseModel):
id: str
email: str
@ -603,6 +636,7 @@ class Me(BaseModel):
mobile_number: str | None
avatar_url: str | None
my_tournaments: list[MyTournament]
secondary_emails: list[SecondaryEmailOut]
@router.get("/me", response_model=Me)
@ -642,6 +676,10 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
"SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)",
user.user_id,
)
secondary_email_rows = await conn.fetch(
"SELECT id::text AS id, email::text AS email FROM user_secondary_email WHERE user_id = $1 ORDER BY created_at",
user.user_id,
)
organizations = []
for m in membership_rows:
@ -704,6 +742,7 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
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,
secondary_emails=[SecondaryEmailOut(id=r["id"], email=r["email"]) for r in secondary_email_rows],
)
@ -861,3 +900,121 @@ async def confirm_email_change(body: EmailChangeConfirm) -> Me:
"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"]))
# --- Sekundær e-postadresse (kun det enkle tilfellet -- se FEATURE_BACKLOG.md
# "Én person, flere e-postadresser" for hvorfor ekte konto-sammenslåing er
# BEVISST utenfor omfang her) -------------------------------------------------
# Samme to-stegs bevis-eierskap-mønster som e-postbytte over, men ADDITIVT
# (legger til en ny rad i user_secondary_email) i stedet for å erstatte
# app_user.email. En verifisert sekundær e-post kan deretter brukes til
# innlogging (magic-link OG passord, se verify_magic_link/login_with_password)
# -- den løses til DENNE kontoen, aldri til en ny, separat konto.
class SecondaryEmailRequest(BaseModel):
email: EmailStr
@router.post("/secondary-email")
async def request_secondary_email(
body: SecondaryEmailRequest,
user: CurrentUser = Depends(get_current_user),
) -> dict:
email = body.email.lower()
async with plain_connection() as conn:
primary = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
if primary is not None:
raise app_error(
409,
"DUPLICATE",
"Denne adressen er allerede en konto sin hovedadresse. Ekte "
"konto-sammenslåing støttes ikke ennå.",
)
existing_secondary = await conn.fetchval(
"SELECT user_id::text FROM user_secondary_email WHERE email = $1", email
)
if existing_secondary is not None:
code = "DUPLICATE" if existing_secondary != user.user_id else "ALREADY_ADDED"
raise app_error(
409,
code,
"Denne adressen er allerede lagt til"
+ ("på kontoen din." if existing_secondary == user.user_id else " 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 secondary_email_token (user_id, email, token_hash, expires_at) VALUES ($1, $2, $3, $4)",
user.user_id,
email,
_hash_secret(raw_token),
expires_at,
)
if settings.DEV_LOG_MAGIC_LINKS:
print(f"[DEV] Sekundær e-post-lenke for {email}: {raw_token}", flush=True)
elif settings.SMTP_CONFIGURED:
try:
await send_secondary_email_verification(email, raw_token)
except Exception:
traceback.print_exc()
return {"status": "ok"}
class SecondaryEmailConfirm(BaseModel):
token: str
@router.post("/secondary-email/confirm", response_model=Me)
async def confirm_secondary_email(body: SecondaryEmailConfirm) -> Me:
token_hash = _hash_secret(body.token)
async with plain_connection() as conn:
token_row = await conn.fetchrow(
"""
UPDATE secondary_email_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, email::text AS email
""",
token_hash,
)
if token_row is None:
raise app_error(401, "INVALID_SECONDARY_EMAIL_LINK", "Lenken er ugyldig, brukt eller utløpt.")
# Re-sjekk begge unikhetsbetingelsene -- adressen kan ha blitt tatt
# (som hovedadresse ELLER som en annen kontos sekundæradresse) i
# tiden MELLOM forespørsel og bekreftelse.
taken_primary = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", token_row["email"])
if taken_primary is not None:
raise app_error(409, "DUPLICATE", "Denne adressen ble tatt i bruk av en konto i mellomtiden.")
taken_secondary = await conn.fetchval(
"SELECT user_id FROM user_secondary_email WHERE email = $1", token_row["email"]
)
if taken_secondary is not None:
raise app_error(409, "DUPLICATE", "Denne adressen ble lagt til av en annen konto i mellomtiden.")
async with translate_db_errors():
await conn.execute(
"INSERT INTO user_secondary_email (user_id, email) VALUES ($1, $2)",
token_row["user_id"],
token_row["email"],
)
return await me(CurrentUser(user_id=token_row["user_id"]))
@router.delete("/secondary-email/{secondary_email_id}", status_code=204)
async def delete_secondary_email(
secondary_email_id: str,
user: CurrentUser = Depends(get_current_user),
) -> None:
async with plain_connection() as conn:
row = await conn.fetchrow(
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE id = $1", secondary_email_id
)
if row is None:
raise app_error(404, "NOT_FOUND", "Adressen finnes ikke.")
if row["user_id"] != user.user_id:
raise app_error(403, "NOT_OWNER", "Denne adressen tilhører ikke deg.")
await conn.execute("DELETE FROM user_secondary_email WHERE id = $1", secondary_email_id)

View file

@ -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, Mail, ShieldCheck, ShieldOff, User, X } from "lucide-react"
import { ArrowLeft, Camera, KeyRound, Lock, Mail, Plus, ShieldCheck, ShieldOff, Trash2, User, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@ -31,6 +31,7 @@ type Me = {
mobile_country_code: string | null
mobile_number: string | null
avatar_url: string | null
secondary_emails: { id: string; email: string }[]
}
export function AccountSettings() {
@ -105,6 +106,8 @@ export function AccountSettings() {
<EmailSection email={me.email} />
<SecondaryEmailSection secondaryEmails={me.secondary_emails} 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">
@ -507,6 +510,152 @@ function EmailSection({ email }: { email: string }) {
)
}
// Én person, flere e-postadresser (FEATURE_BACKLOG.md) -- kun det enkle
// tilfellet: en FRI, ukrevd adresse legges til og verifiseres, og kan
// deretter brukes til innlogging i tillegg til hovedadressen. Ekte
// konto-sammenslåing (adressen tilhører allerede en annen konto) er
// bevisst IKKE støttet -- backend avviser da med en tydelig 409 DUPLICATE.
function SecondaryEmailSection({
secondaryEmails,
onChanged,
}: {
secondaryEmails: { id: string; email: string }[]
onChanged: () => void
}) {
const [adding, setAdding] = useState(false)
const [newEmail, setNewEmail] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false)
const [removingId, setRemovingId] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await fetch("/auth/secondary-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ 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)
}
}
async function handleRemove(id: string) {
setRemovingId(id)
try {
const res = await fetch(`/auth/secondary-email/${id}`, { method: "DELETE", credentials: "include" })
if (res.ok || res.status === 404) onChanged()
} finally {
setRemovingId(null)
}
}
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">Andre e-postadresser</h2>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Har du fått en turneringsinvitasjon en annen adresse enn {""}
{"hovedadressen din"}? Legg den til her, kan du logge inn med begge og turneringer/
data knyttet til den andre adressen dukker opp kontoen din.
</p>
{secondaryEmails.length > 0 && (
<ul className="flex flex-col gap-2">
{secondaryEmails.map((se) => (
<li
key={se.id}
className="flex items-center justify-between gap-2 rounded-xl border border-border bg-background px-3 py-2.5"
>
<span className="truncate text-sm font-semibold text-foreground">{se.email}</span>
<Button
type="button"
variant="ghost"
size="icon"
disabled={removingId === se.id}
onClick={() => handleRemove(se.id)}
className="size-8 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
aria-label={`Fjern ${se.email}`}
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
</li>
))}
</ul>
)}
{sent ? (
<p className="text-sm font-medium text-primary text-pretty">
Sjekk innboksen til {newEmail.trim()} åpne lenken der for å bekrefte at du eier
adressen. Ingenting legges til før den er bekreftet.
</p>
) : adding ? (
<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="secondary-email" className="text-sm font-semibold">
Ny adresse
</Label>
<Input
id="secondary-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={() => setAdding(false)}
className="h-12 shrink-0 rounded-xl font-semibold"
>
Avbryt
</Button>
</div>
</form>
) : (
<Button
type="button"
variant="outline"
onClick={() => setAdding(true)}
className="h-11 w-fit rounded-xl font-semibold"
>
<Plus aria-hidden="true" className="size-4" />
Legg til adresse
</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)

View file

@ -10,6 +10,10 @@ type Status = "verifying" | "success" | "error"
export function VerifyEmailForm() {
const searchParams = useSearchParams()
const token = searchParams.get("token")
// ?kind=secondary (satt av send_secondary_email_verification, app/email.py)
// skiller "legg til ekstra adresse" fra det vanlige "bytt hovedadresse"-
// løpet -- samme sides UI, ulikt endepunkt/budskap.
const isSecondary = searchParams.get("kind") === "secondary"
const [status, setStatus] = useState<Status>(token ? "verifying" : "error")
const [email, setEmail] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
@ -21,7 +25,7 @@ export function VerifyEmailForm() {
}
async function verify() {
try {
const res = await fetch("/auth/profile/email/confirm", {
const res = await fetch(isSecondary ? "/auth/secondary-email/confirm" : "/auth/profile/email/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
@ -31,8 +35,10 @@ export function VerifyEmailForm() {
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)
if (!isSecondary) {
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.")
@ -40,7 +46,7 @@ export function VerifyEmailForm() {
}
}
void verify()
}, [token])
}, [token, isSecondary])
if (status === "verifying") {
return (
@ -63,11 +69,20 @@ export function VerifyEmailForm() {
<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 && (
<h2 className="text-2xl font-extrabold tracking-tight text-balance">
{isSecondary ? "Adresse lagt til" : "E-post oppdatert"}
</h2>
{isSecondary ? (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Du logger inn med <span className="font-semibold text-foreground">{email}</span>.
Adressen er knyttet til kontoen din du kan logge inn med den i tillegg til
hovedadressen.
</p>
) : (
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