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:
parent
df08339654
commit
b376f095be
8 changed files with 580 additions and 40 deletions
|
|
@ -340,7 +340,8 @@
|
||||||
"Bash(python3 -m py_compile app/routers/auth.py)",
|
"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(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(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": [
|
"additionalDirectories": [
|
||||||
"/opt/teeoff/deploy",
|
"/opt/teeoff/deploy",
|
||||||
|
|
|
||||||
49
017_secondary_email.sql
Normal file
49
017_secondary_email.sql
Normal 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;
|
||||||
84
CLAUDE.md
84
CLAUDE.md
|
|
@ -2105,31 +2105,87 @@ Ferdig og verifisert:
|
||||||
avhengighets-bivirkning, ingen backend-kode rørt). Begge containere
|
avhengighets-bivirkning, ingen backend-kode rørt). Begge containere
|
||||||
boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
|
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:
|
Neste steg:
|
||||||
1. **Venter på brukerens retningsbekreftelse:** redesign av dashbordets
|
1. **Pågår, samme økt:** bygge de to gjenværende bekreftede punktene fra
|
||||||
tom-tilstand ved første innlogging (mindre organisator-vridd, se
|
dashboard/konto-runden (2026-07-21) — sekundær e-postadresse (enkelt
|
||||||
FEATURE_BACKLOG.md) — forslag lagt frem 2026-07-20, ikke bygget.
|
tilfelle, gjenbruker ADR-032s token-mønster) og HCP-historikk over tid.
|
||||||
2. **Notert, IKKE designet i detalj:** én person med flere e-post-adresser
|
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
|
— legge til en verifisert sekundær-adresse fra dashbordet, med
|
||||||
turneringer/data som "dukker opp"/slås sammen deretter, og fremtidige
|
turneringer/data som "dukker opp"/slås sammen deretter, og fremtidige
|
||||||
innlogginger med enten adresse. Fanget grundig i FEATURE_BACKLOG.md,
|
innlogginger med enten adresse. Fanget grundig i FEATURE_BACKLOG.md,
|
||||||
inkl. et reelt uløst spørsmål (ekte konto-sammenslåing hvis adressen
|
inkl. et reelt uløst spørsmål (ekte konto-sammenslåing hvis adressen
|
||||||
allerede tilhører en annen eksisterende konto) som trenger egen,
|
allerede tilhører en annen eksisterende konto) som trenger egen,
|
||||||
separat designrunde.
|
separat designrunde. **Merk:** dette overlapper med punkt 1 sin
|
||||||
3. Personlig landingsside + profil (ADR-031) OG e-post/mobil (ADR-032) er
|
"sekundær e-postadresse" — punkt 1 er det ENKLE, avgrensede tilfellet
|
||||||
BEGGE LIVE. Naturlig oppfølging: deltaker-tilgang (uten org-medlemskap)
|
(fri adresse), dette punktet er det harde tilfellet (adressen tilhører
|
||||||
til lag-chat/scorekort — bevisst utenfor omfang, se FEATURE_BACKLOG.md.
|
allerede en annen konto).
|
||||||
4. **Punkt 2 fra 2026-07-20-runden (midlertidige spillere):** forslag klart
|
5. **Deltaker-tilgang til lag-chat/scorekort er nå BYGGET OG LIVE**
|
||||||
i FEATURE_BACKLOG.md, tre åpne spørsmål, ingen kode skrevet.
|
(2026-07-21, se status over) — ADR-031s tidligere notert begrensning
|
||||||
5. Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
|
er dermed tettet.
|
||||||
|
6. Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
|
||||||
forespørsel: offline-scoreregistrering er ALDRI browser-testet i
|
forespørsel: offline-scoreregistrering er ALDRI browser-testet i
|
||||||
praksis (kun kodegjennomgang + build-verifisering, se ADR-028). Bruker
|
praksis (kun kodegjennomgang + build-verifisering, se ADR-028). Bruker
|
||||||
bør selv åpne et scorekort, skru på Chrome DevTools sin Offline-bryter,
|
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
|
registrere et par slag, skru nettet på igjen, og bekrefte at de faktisk
|
||||||
synkes — først da er flyten reelt bevist.
|
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
|
7. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for
|
||||||
ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger
|
ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger
|
||||||
(bevisst utsatt i ADR-025).
|
(bevisst utsatt i ADR-025).
|
||||||
|
|
|
||||||
|
|
@ -1049,6 +1049,9 @@ gjort i denne runden. Notifikasjons-/aktivitetsfeed og HCP-historikk over
|
||||||
tid også bevisst utenfor omfang v1 (samme begrunnelse som opprinnelig
|
tid også bevisst utenfor omfang v1 (samme begrunnelse som opprinnelig
|
||||||
forslag).
|
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
|
**Scratch-verifisert, 15 sjekker** (profil-CRUD komplett, inkl. sletting av
|
||||||
enkeltfelt og avatar; en EKTE ren spiller uten org-medlemskap ser riktig
|
enkeltfelt og avatar; en EKTE ren spiller uten org-medlemskap ser riktig
|
||||||
`my_tournaments`; den kritiske sikkerhetssjekken: samme spiller får nå se
|
`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
|
## 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
|
Brukeren påpekte 2026-07-20 at dagens tomme-tilstand ("Du har ingen
|
||||||
|
|
|
||||||
41
app/email.py
41
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}"
|
link = f"{settings.PUBLIC_BASE_URL}/verify-email?token={raw_token}"
|
||||||
body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, link=link)
|
body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, link=link)
|
||||||
await to_thread(_send_sync, to_email, template["subject"], body)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,12 @@ from ..auth import (
|
||||||
)
|
)
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..db import org_connection, plain_connection
|
from ..db import org_connection, plain_connection
|
||||||
from ..email import send_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
|
from ..errors import app_error, translate_db_errors
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
@ -244,6 +249,17 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request:
|
||||||
raise app_error(401, "INVALID_MAGIC_LINK", "Lenken er ugyldig, brukt eller utløpt.")
|
raise app_error(401, "INVALID_MAGIC_LINK", "Lenken er ugyldig, brukt eller utløpt.")
|
||||||
email = token_row["email"]
|
email = token_row["email"]
|
||||||
|
|
||||||
|
# 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 secondary_owner_id is not None:
|
||||||
|
user_id = secondary_owner_id
|
||||||
|
else:
|
||||||
placeholder_name = email.split("@")[0]
|
placeholder_name = email.split("@")[0]
|
||||||
user_id = await conn.fetchval(
|
user_id = await conn.fetchval(
|
||||||
"""
|
"""
|
||||||
|
|
@ -257,9 +273,10 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request:
|
||||||
token_row["locale"],
|
token_row["locale"],
|
||||||
)
|
)
|
||||||
if user_id is None:
|
if user_id is None:
|
||||||
# En annen samtidig verifisering for samme e-post vant innsettingen
|
# En annen samtidig verifisering for samme e-post vant
|
||||||
# (eller brukeren fantes allerede) -- preferred_locale røres IKKE
|
# innsettingen (eller brukeren fantes allerede) --
|
||||||
# her, kun ved førstegangsopprettelse (se modul-docstring).
|
# 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)
|
user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
|
||||||
|
|
||||||
# Koble enhver player-rad (ADR-017) OG enhver ventende
|
# Koble enhver player-rad (ADR-017) OG enhver ventende
|
||||||
|
|
@ -287,6 +304,17 @@ async def login_with_password(body: PasswordLoginRequest, response: Response, re
|
||||||
user_row = await conn.fetchrow(
|
user_row = await conn.fetchrow(
|
||||||
"SELECT id::text AS id, password_hash FROM app_user WHERE email = $1", email
|
"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 /
|
# Identisk feil uansett årsak (e-post finnes ikke / intet passord satt /
|
||||||
# feil passord) -- unngår enumerering, samme prinsipp som magic-link.
|
# feil passord) -- unngår enumerering, samme prinsipp som magic-link.
|
||||||
if (
|
if (
|
||||||
|
|
@ -582,6 +610,11 @@ class MyTournament(BaseModel):
|
||||||
my_match_id: str | None
|
my_match_id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class SecondaryEmailOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
class Me(BaseModel):
|
class Me(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
email: str
|
email: str
|
||||||
|
|
@ -603,6 +636,7 @@ class Me(BaseModel):
|
||||||
mobile_number: str | None
|
mobile_number: str | None
|
||||||
avatar_url: str | None
|
avatar_url: str | None
|
||||||
my_tournaments: list[MyTournament]
|
my_tournaments: list[MyTournament]
|
||||||
|
secondary_emails: list[SecondaryEmailOut]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=Me)
|
@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)",
|
"SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)",
|
||||||
user.user_id,
|
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 = []
|
organizations = []
|
||||||
for m in membership_rows:
|
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"],
|
mobile_number=user_row["mobile_number"],
|
||||||
avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
|
avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
|
||||||
my_tournaments=my_tournaments,
|
my_tournaments=my_tournaments,
|
||||||
|
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"]
|
"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"]))
|
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)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Camera, KeyRound, Lock, 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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
@ -31,6 +31,7 @@ type Me = {
|
||||||
mobile_country_code: string | null
|
mobile_country_code: string | null
|
||||||
mobile_number: string | null
|
mobile_number: string | null
|
||||||
avatar_url: string | null
|
avatar_url: string | null
|
||||||
|
secondary_emails: { id: string; email: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountSettings() {
|
export function AccountSettings() {
|
||||||
|
|
@ -105,6 +106,8 @@ export function AccountSettings() {
|
||||||
|
|
||||||
<EmailSection email={me.email} />
|
<EmailSection email={me.email} />
|
||||||
|
|
||||||
|
<SecondaryEmailSection secondaryEmails={me.secondary_emails} onChanged={loadMe} />
|
||||||
|
|
||||||
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
|
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
|
||||||
|
|
||||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
||||||
|
|
@ -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 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.
|
||||||
|
</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 }) {
|
function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) {
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ type Status = "verifying" | "success" | "error"
|
||||||
export function VerifyEmailForm() {
|
export function VerifyEmailForm() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const token = searchParams.get("token")
|
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 [status, setStatus] = useState<Status>(token ? "verifying" : "error")
|
||||||
const [email, setEmail] = useState<string | null>(null)
|
const [email, setEmail] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
@ -21,7 +25,7 @@ export function VerifyEmailForm() {
|
||||||
}
|
}
|
||||||
async function verify() {
|
async function verify() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/auth/profile/email/confirm", {
|
const res = await fetch(isSecondary ? "/auth/secondary-email/confirm" : "/auth/profile/email/confirm", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
|
@ -31,8 +35,10 @@ export function VerifyEmailForm() {
|
||||||
const body = await res.json().catch(() => null)
|
const body = await res.json().catch(() => null)
|
||||||
throw new Error(body?.detail?.message ?? "Lenken er ugyldig eller utløpt.")
|
throw new Error(body?.detail?.message ?? "Lenken er ugyldig eller utløpt.")
|
||||||
}
|
}
|
||||||
|
if (!isSecondary) {
|
||||||
const result: { email: string } = await res.json()
|
const result: { email: string } = await res.json()
|
||||||
setEmail(result.email)
|
setEmail(result.email)
|
||||||
|
}
|
||||||
setStatus("success")
|
setStatus("success")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
|
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
|
||||||
|
|
@ -40,7 +46,7 @@ export function VerifyEmailForm() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void verify()
|
void verify()
|
||||||
}, [token])
|
}, [token, isSecondary])
|
||||||
|
|
||||||
if (status === "verifying") {
|
if (status === "verifying") {
|
||||||
return (
|
return (
|
||||||
|
|
@ -63,11 +69,20 @@ export function VerifyEmailForm() {
|
||||||
<CheckCircle2 aria-hidden="true" className="size-8 text-primary" />
|
<CheckCircle2 aria-hidden="true" className="size-8 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="text-2xl font-extrabold tracking-tight text-balance">E-post oppdatert</h2>
|
<h2 className="text-2xl font-extrabold tracking-tight text-balance">
|
||||||
{email && (
|
{isSecondary ? "Adresse lagt til" : "E-post oppdatert"}
|
||||||
|
</h2>
|
||||||
|
{isSecondary ? (
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Adressen er nå 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">
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
Du logger nå inn med <span className="font-semibold text-foreground">{email}</span>.
|
Du logger nå inn med <span className="font-semibold text-foreground">{email}</span>.
|
||||||
</p>
|
</p>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue