From b376f095be60e870d2abc261c2e5f7f7588f7234 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Wed, 22 Jul 2026 06:14:31 +0200 Subject: [PATCH] Update Todos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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å. --- .claude/settings.local.json | 3 +- 017_secondary_email.sql | 49 ++++++ CLAUDE.md | 84 ++++++++-- FEATURE_BACKLOG.md | 72 ++++++++ app/email.py | 41 +++++ app/routers/auth.py | 191 ++++++++++++++++++++-- frontend/components/account-settings.tsx | 151 ++++++++++++++++- frontend/components/verify-email-form.tsx | 29 +++- 8 files changed, 580 insertions(+), 40 deletions(-) create mode 100644 017_secondary_email.sql 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. +

+ ) : adding ? ( +
+
+ + setNewEmail(e.target.value)} + className="h-12 rounded-xl" + /> +
+
+ + +
+
+ ) : ( + + )} + + {error &&

{error}

} +
+ ) +} + function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) { const [password, setPassword] = useState("") const [submitting, setSubmitting] = useState(false) diff --git a/frontend/components/verify-email-form.tsx b/frontend/components/verify-email-form.tsx index e6a84da..0d0d227 100644 --- a/frontend/components/verify-email-form.tsx +++ b/frontend/components/verify-email-form.tsx @@ -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(token ? "verifying" : "error") const [email, setEmail] = useState(null) const [error, setError] = useState(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() {