diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index cc4d655..1a042d8 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -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",
diff --git a/017_secondary_email.sql b/017_secondary_email.sql
new file mode 100644
index 0000000..c7c2cba
--- /dev/null
+++ b/017_secondary_email.sql
@@ -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;
diff --git a/CLAUDE.md b/CLAUDE.md
index 08a2fd5..4c0a542 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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).
diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md
index 3fadd63..a4d1d09 100644
--- a/FEATURE_BACKLOG.md
+++ b/FEATURE_BACKLOG.md
@@ -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
diff --git a/app/email.py b/app/email.py
index 65f30b5..d87e089 100644
--- a/app/email.py
+++ b/app/email.py
@@ -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)
diff --git a/app/routers/auth.py b/app/routers/auth.py
index 7263622..4325934 100644
--- a/app/routers/auth.py
+++ b/app/routers/auth.py
@@ -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)
diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx
index 72f98d9..75911d2 100644
--- a/frontend/components/account-settings.tsx
+++ b/frontend/components/account-settings.tsx
@@ -8,7 +8,7 @@
import type React from "react"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
-import { ArrowLeft, Camera, KeyRound, Lock, 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() {
+
+
@@ -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(null)
+ const [sent, setSent] = useState(false)
+ const [removingId, setRemovingId] = useState(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 (
+
+
+
+
+
+
Andre e-postadresser
+
+
+
+ Har du fått en turneringsinvitasjon på en annen adresse enn {""}
+ {"hovedadressen din"}? Legg den til her, så kan du logge inn med begge — og turneringer/
+ data knyttet til den andre adressen dukker opp på kontoen din.
+
+
+ {secondaryEmails.length > 0 && (
+
+ {secondaryEmails.map((se) => (
+
+ {se.email}
+
+
+ ))}
+
+ )}
+
+ {sent ? (
+
+ Sjekk innboksen til {newEmail.trim()} — åpne lenken der for å bekrefte at du eier
+ adressen. Ingenting legges til før den er bekreftet.
+