diff --git a/077_account_merge.sql b/077_account_merge.sql
new file mode 100644
index 0000000..dc0f9fb
--- /dev/null
+++ b/077_account_merge.sql
@@ -0,0 +1,42 @@
+-- =====================================================================
+-- TeeCup — kontosammenslåing, "Del 2" av flere-e-postadresser (migrasjon 077)
+-- =====================================================================
+-- Oppfølging av ADR-032/migrasjon 017 (sekundær e-post) -- "Del 1" dekket
+-- KUN det enkle tilfellet (fri, ukrevd adresse). Del 2: hva skjer når
+-- adressen som legges til ALLEREDE er en annen, eksisterende kontos
+-- primær- eller sekundæradresse -- samme reelle person har endt opp med
+-- to separate TeeCup-kontoer. Selvbetjent (bruker bekreftet eksplisitt).
+--
+-- Samme token-hash-og-utløp-mønster som magic_link_token (004) ->
+-- email_change_token (016) -> secondary_email_token (017): bevis
+-- eierskap av MÅL-kontoens e-post FØR selve sammenslåingen utføres.
+-- Lenken sendes til MÅL-kontoens (taperens) e-post -- beviser eierskap av
+-- DEN kontoen, ikke bare at initiativtakeren (keeperen) fortsatt er
+-- innlogget, samme prinsipp som ADR-032 Beslutning B.
+--
+-- `initiator_user_id`/`target_user_id` er BEVISST asymmetriske roller,
+-- ikke to likeverdige parter: initiator (keeper) er den som ber om
+-- sammenslåingen fra en aktiv økt, target (taper) er kontoen som slås
+-- INN i keeperen og til slutt slettes (se app/account_merge.py -- ingen
+-- sesjons-tilbakekallingsmekanisme finnes i denne appen, sletting av
+-- app_user-raden er eneste måte å tvinge ut en gjenlevende
+-- øktinformasjonskapsel for taper-kontoen).
+-- =====================================================================
+
+\set ON_ERROR_STOP on
+
+CREATE TABLE account_merge_token (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ initiator_user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
+ target_user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
+ token_hash text NOT NULL UNIQUE,
+ expires_at timestamptz NOT NULL,
+ consumed_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CHECK (initiator_user_id <> target_user_id)
+);
+
+CREATE INDEX ON account_merge_token (initiator_user_id);
+CREATE INDEX ON account_merge_token (target_user_id);
+
+GRANT SELECT, INSERT, UPDATE ON account_merge_token TO teecup_app;
diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md
index 69dbd36..7fefceb 100644
--- a/ARCHITECTURE_DECISIONS.md
+++ b/ARCHITECTURE_DECISIONS.md
@@ -7819,6 +7819,160 @@ Containeren startet rent.
---
+## ADR-080: Kontosammenslåing, selvbetjent ("Del 2" av flere e-postadresser, ADR-032) (2026-08-17)
+
+Foranledning: "flere e-postadresser" (ADR-032, migrasjon 016/017) løste å
+legge til en FRI, ukrevd adresse på egen konto ("Del 1"). Når adressen
+som legges til ALLEREDE eies av en annen, eksisterende konto (samme
+person har endt opp med to separate TeeCup-kontoer, hver med egen
+historikk), avviste `request_secondary_email` dette med en 409 og
+teksten "Ekte konto-sammenslåing støttes ikke ennå." Denne runden bygger
+"Del 2". Bekreftet med bruker (AskUserQuestion): (1) selvbetjening --
+spilleren gjør det selv, ikke et superadmin-verktøy, (2) bygg hele
+sammenslåingen i én runde, ikke faseinndelt (forhåndsvisning + faktisk
+utførelse samtidig).
+
+**Modell: "keeper" (initiativtaker, aktiv økt, kontoen som OVERLEVER) vs.
+"taper" (målet, kontoen som slås inn og SLETTES).** Keeperens økt beviser
+keeper-eierskap; en lenke sendt til taperens e-post beviser taper-
+eierskap -- samme prinsipp som e-postbytte (ADR-032 Beslutning B): lenken
+sendes til MÅLET, ikke til avsenderen, fordi det er MÅL-eierskapet som
+må bevises.
+
+**Gjenbrukt, ikke oppfunnet på nytt:** samme token-mønster som
+`magic_link_token`/`email_change_token`/`secondary_email_token` --
+`token_hash` (SHA-256 via `_hash_secret()`) + `expires_at` +
+`consumed_at`, konsumert atomisk via `UPDATE ... WHERE consumed_at IS
+NULL AND expires_at > now() RETURNING ...`. Ny tabell `account_merge_
+token` (migrasjon 077): `initiator_user_id` (keeper), `target_user_id`
+(taper, låst på forespørsel-tidspunktet), `CHECK (initiator_user_id <>
+target_user_id)`.
+
+**E-post → bruker-oppslag faktorert ut** til `resolve_user_id_by_email()`
+(`app/auth.py`) -- sjekker primær adresse (`app_user.email`) så sekundær
+(`user_secondary_email`). Erstatter identisk, duplisert logikk i
+`verify_magic_link` og `login_with_password` (ren refaktorering, ingen
+atferdsendring på de to eksisterende innloggingsveiene) og gjenbrukes av
+merge-forespørsel-endepunktet.
+
+**RLS-grensen avgjør transaksjonsstrukturen -- verifisert direkte mot
+skjemaet, ikke antatt.** To grupper tabeller:
+- IKKE RLS-beskyttet (`plain_connection()`, kan holdes i ÉN global
+ transaksjon): `app_user`, `organization_membership` (bevisst UTEN RLS,
+ se `001_initial_schema.sql` linje 364-366 -- "håndteres av auth-laget"),
+ `user_secondary_email`, `friendship`, `friend_categorization`,
+ `notification`, `user_notification_email_pref`, `push_subscription`,
+ `handicap_history`, `personal_course`, `round`, `round_participant`,
+ `round_message*` (inkl. `round_message_reaction`), `round_shot`,
+ `round_participant_flag_plant`.
+- RLS-beskyttet (`org_isolation`-policy, `organization_id =
+ app.current_org`, KREVER `org_connection(org_id)` per org): `player`,
+ `team_roster`, `tournament_registration`, `tournament_participant`,
+ `order_of_merit_team_member`, `tournament`, `lineup_lock`,
+ `organization_invitation`, `message`/`message_reaction`/
+ `message_comment`, `tournament_round_participant_flag_plant`, og alt
+ som kaskaderer fra disse.
+
+Konsekvens: en sammenslåing kan ALDRI bli én atomisk transaksjon på tvers
+av alt -- blir N+1 transaksjoner (én global + én per berørt org). Samme
+mønster som allerede etablert praksis i `/auth/me` (kommentar `app/
+routers/auth.py` rundt linje 786-795: "dette er den eneste måten å gjøre
+det på uten å endre selve RLS-modellen"). Berørte org-er finnes via
+`organization_membership` (plain) UNION `player_organizations_for_user()`
+(SECURITY DEFINER, migrasjon 015, ADR-031) for BEGGE kontoer.
+
+**Konfliktkart** (fra full FK-inventering):
+
+| Tabell | Løsningsregel |
+|---|---|
+| `organization_membership` | Behold HØYEST rolle (owner > admin > member) |
+| `player` + avhengige (`team_roster`/`tournament_registration`/`tournament_participant`/`order_of_merit_team_member`) | Har keeper allerede en player-rad i orgen: re-pek taperens avhengigheter dit, slett taperens duplikat (dedup på hver sin `UNIQUE(x, player_id)`). Har keeper ingen: enkel re-peking. |
+| `friendship` | Selv-lenke (de var venner med HVERANDRE) slettes (ville brutt `CHECK(requester <> addressee)`). Delt tredjepart-venn: behold `accepted` fremfor `pending`. |
+| `friend_categorization` | Samme selvlenke-sjekk + union av kategori-sett for delt venn |
+| `user_notification_email_pref` | Behold keeperens rad, dropp taperens duplikat |
+| `round_message_reaction`/`message_reaction` | Behold keeperens reaksjon om den finnes, ellers overfør taperens |
+| `user_secondary_email` | Taperens PRIMÆRE e-post blir en ny sekundær-e-post på keeper |
+| Alt annet med `user_id`/`author_user_id`/`created_by`/`invited_by` osv. | Trygt å re-peke direkte, ingen dedup |
+
+**Ingen sesjon-tilbakekallingsmekanisme finnes** (`get_current_user`
+sjekker kun `app_user`-radens eksistens). Eneste måte å tvinge ut en
+gjenlevende øktinformasjonskapsel for taper-kontoen er en ekte `DELETE
+FROM app_user` -- IKKE en myk "alias"-løsning. Dette er siste, irreversible
+steg i `execute_merge()`, kjørt kun etter at alt annet har lyktes.
+CASCADE dekker resten av FK-ene automatisk.
+
+**En reell bug funnet og fikset via testsuiten, FØR noe var i nærheten av
+produksjon:** seks RLS-beskyttede tabeller (`tournament`, `lineup_lock`,
+`organization_invitation`, `message`, `message_comment`,
+`tournament_round_participant_flag_plant`) samt `message_reaction` ble
+først ved en feil lagt i den globale "trygt å re-peke"-løkken (ment kun
+for tabeller UTEN RLS). Symptomet var IKKE en stille RLS-no-op som man
+skulle tro, men et krasj: `InvalidTextRepresentationError: invalid input
+syntax for type uuid: ""`. Rotårsak: en Postgres GUC-kvirk der
+`current_setting('app.current_org', true)` returnerer tom streng (`''`),
+ikke NULL, på en POOLET forbindelse som TIDLIGERE hadde en committet
+`SET LOCAL app.current_org` fra en annen transaksjon og senere
+gjenbrukes via `plain_connection()` -- `''::uuid`-cast i RLS-policyen
+kaster i stedet for å filtrere stille. Fikset ved å flytte de seks
+tabellene + `message_reaction`-dedup til en egen `_repoint_rls_scoped_
+safe_tables()`, kjørt INNI samme `org_connection(org_id)`-transaksjon som
+player-sammenslåingen. Verifisert via full testsuite-kjøring: gikk fra
+5 feilet/90 bestått til 95/95 bestått.
+
+**Nye endepunkter** (`app/routers/account_merge.py`, egen fil):
+`POST /auth/account-merge/preview` (sesjon påkrevd, les-only), `POST
+/auth/account-merge/request` (sesjon påkrevd, oppretter token + sender
+e-post), `GET /auth/account-merge/token/{token}` (ingen sesjon, fersk
+forhåndsvisning, konsumerer IKKE token), `POST /auth/account-merge/
+confirm` (ingen sesjon, konsumerer token atomisk + kjører
+`execute_merge()`).
+
+**Frontend:** ny seksjon "Slå sammen med en annen konto" i
+`account-settings.tsx` (samme kort-mønster som `SecondaryEmailSection`)
+-- e-post → forhåndsvisning inline → "Send bekreftelse". Ny side
+`app/kontosammenslaing/[token]/page.tsx` + `AccountMergeConfirmForm` --
+laster FERSK forhåndsvisning ved åpning, viser en tydelig "dette kan
+ikke angres"-advarsel (samme destructive-tint-mønster som `round-
+header.tsx` sin inline slett-bekreft, ikke `window.confirm()`), krever
+et eksplisitt "Bekreft sammenslåing"-trykk -- ALDRI automatisk ved
+lenke-åpning.
+
+**Verifisert:**
+1. `tests/test_account_merge.py`, 12 nye tester -- hver rad i
+ konfliktkartet dekket eksplisitt (org-rolle-kollisjon, player-
+ sammenslåing inkl. ekte `team_roster`-kollisjon, venn-selvlenke,
+ delt-venn-dedup, sekundær-e-post-overføring, utløpt/allerede-konsumert
+ token), pluss én full ende-til-ende-test. Full backend-suite: 95/95
+ bestått.
+2. `tsc --noEmit` rent + 45/45 vitest.
+3. Scratch-database + scratch `teecup_api` (port 18108) + lokal `next
+ dev` (port 13108): to ekte kontoer, keeper medlem i org A + admin i
+ org B, taper eier i org A + admin i org B (rolle-kollisjon), begge med
+ en player-rad i org A (team_roster-kollisjon), venner med hverandre.
+ Logget inn som keeper, forhåndsvisning i kontoinnstillinger viste
+ korrekt konfliktkart (org B: administrator, org A: eier -- oppgradert
+ fra medlem, 1 dobbeltregistrering ryddes, 1 runde, 1 venn). Hentet
+ bekreftelseslenke fra containerlogg, åpnet i en HELT separat,
+ ikke-autentisert nettleserkontekst -- fersk forhåndsvisning viste
+ identisk konfliktkart. Bekreftet: suksess-skjerm vist. Verifisert
+ direkte mot databasen: taperens `app_user`-rad borte, taperens
+ e-post nå keeperens sekundæradresse, org-roller korrekte (eier/
+ administrator), kun én player-rad i org A (dedup fungerte), kun én
+ venneforespørsel (selvlenke slettet). Innlogging med taperens gamle
+ e-post routet korrekt til keeper-kontoen (bekreftet ved at 2FA-
+ oppsett umiddelbart ble krevd, fordi keeper nå er org-eier). Lys+mørk
+ bekreftet på bekreftelsessiden. Scratch-stacken fullstendig revet ned
+ (Docker-container, database+rolle, MinIO-bucket, `next dev`) -- ekte
+ `teecup_db`/`teecup_api`/`teecup_frontend` urørt gjennom hele
+ verifiseringen.
+4. Migrasjon 077 vist og bekreftet av bruker før kjøring mot ekte
+ `teecup_db` (additiv -- én ny tabell, ingen skjemaendring på
+ eksisterende tabeller).
+
+**Rullet ut: ikke ennå.**
+
+---
+
## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 38356e3..8675529 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11754,3 +11754,60 @@ Neste steg:
**Rullet ut 2026-08-16** -- ingen migrasjon, `docker compose build
teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker.
Containeren startet rent.
+
+96. **Kontosammenslåing, selvbetjent (ADR-080, "Del 2" av flere
+ e-postadresser ADR-032) — 2026-08-17.** `request_secondary_email`
+ avviste tidligere en adresse som allerede eide av en ANNEN konto med
+ en 409 og "Ekte konto-sammenslåing støttes ikke ennå." Bekreftet med
+ bruker (AskUserQuestion): selvbetjening (ikke superadmin-verktøy),
+ hele sammenslåingen i én runde (forhåndsvisning + utførelse).
+
+ Ny migrasjon 077: `account_merge_token` (samme token-mønster som
+ `email_change_token`/`secondary_email_token`). Ny delt hjelpefunksjon
+ `resolve_user_id_by_email()` i `app/auth.py`, faktorert ut av
+ duplisert primær/sekundær-oppslag i `verify_magic_link`/`login_with_
+ password`. Ny modul `app/account_merge.py`: `compute_merge_preview()`
+ (les-only) + `execute_merge()` (N+1-transaksjoner -- én global
+ `plain_connection()` + én `org_connection(org_id)` per berørt org,
+ påkrevd av RLS-grensen, se ADR-080 for fullt konfliktkart). Nye
+ endepunkter i egen `app/routers/account_merge.py` (`/preview`,
+ `/request`, `GET /token/{token}`, `/confirm`). Ny frontend-seksjon
+ "Slå sammen med en annen konto" i `account-settings.tsx` + ny side
+ `app/kontosammenslaing/[token]/page.tsx`.
+
+ **Fant og fikset underveis (før produksjon, ren testsuite-fangst):**
+ seks RLS-beskyttede tabeller (`tournament`, `lineup_lock`,
+ `organization_invitation`, `message`, `message_comment`,
+ `tournament_round_participant_flag_plant`) + `message_reaction` var
+ feilaktig lagt i den globale "trygt å re-peke uten RLS"-løkken.
+ Krasjet (ikke stille no-op) med `invalid input syntax for type uuid:
+ ""` -- en Postgres GUC-kvirk der `current_setting('app.current_org',
+ true)` returnerer tom streng, ikke NULL, på en pooled forbindelse som
+ tidligere hadde en committet `SET LOCAL` fra en annen transaksjon.
+ Flyttet til egen `_repoint_rls_scoped_safe_tables()`, kjørt inni riktig
+ `org_connection(org_id)`. Testsuite gikk fra 5 feilet/90 bestått til
+ 95/95 bestått.
+
+ **Verifisert:** `tests/test_account_merge.py`, 12 nye tester (hver
+ rad i konfliktkartet + full ende-til-ende), full backend-suite 95/95.
+ `tsc --noEmit` rent + 45/45 vitest. Scratch-database + scratch
+ `teecup_api` (port 18108) + lokal `next dev` (port 13108): to ekte
+ kontoer med org-rolle-kollisjon (medlem→eier), org-repeking uten
+ kollisjon (→administrator), player/team_roster-kollisjon, felles
+ venn. Forhåndsvisning i kontoinnstillinger og fersk forhåndsvisning
+ på bekreftelsessiden (åpnet i egen, helt uautentisert nettleser-
+ kontekst) viste identisk, korrekt konfliktkart. Etter bekreftelse:
+ taperens `app_user`-rad bekreftet slettet direkte mot databasen,
+ taperens e-post nå keeperens sekundæradresse, org-roller korrekte,
+ player/team_roster-dedup korrekt, venn-selvlenke slettet. Innlogging
+ med taperens gamle e-post routet korrekt til keeper-kontoen (utløste
+ umiddelbart 2FA-oppsett, siden keeper nå er org-eier -- god indirekte
+ bekreftelse på at oppslaget faktisk traff riktig konto). Lys+mørk
+ bekreftet på bekreftelsessiden. Scratch-stacken (Docker-container,
+ database+rolle, MinIO-bucket, `next dev`) fullstendig revet ned --
+ ekte `teecup_db` urørt gjennom hele verifiseringen.
+
+ **Migrasjon 077 vist og bekreftet av bruker før kjøring mot ekte
+ `teecup_db`** (additiv -- én ny tabell).
+
+ **Rullet ut: ikke ennå.**
diff --git a/app/account_merge.py b/app/account_merge.py
new file mode 100644
index 0000000..c4e4b57
--- /dev/null
+++ b/app/account_merge.py
@@ -0,0 +1,567 @@
+"""
+Kontosammenslåing ("Del 2" av flere-e-postadresser, ADR-080) -- to separate
+`app_user`-kontoer eid av samme reelle person slås sammen til én.
+
+Selvbetjent (bruker bekreftet eksplisitt): `keeper_id` (initiativtakeren,
+den aktive økten) beholdes, `loser_id` (kontoen bekreftet via en lenke sendt
+til DENS e-post, se app/routers/account_merge.py) slås INN i keeper og
+slettes til slutt.
+
+RLS-grensen avgjør strukturen (verifisert direkte mot skjemaet, ikke
+antatt): `organization_membership`/`app_user`/`friendship`/`notification`/
+`round`/`round_participant`/`round_message*`/`handicap_history`/
+`push_subscription`/`user_secondary_email` m.fl. har INGEN RLS (håndteres
+av app-laget, "auth-laget", se 001_initial_schema.sql linje 364-366) --
+disse slås sammen i ÉN global `plain_connection()`-transaksjon.
+`player`/`team_roster`/`tournament_registration`/`tournament_participant`/
+`order_of_merit_team_member`/`message`/`message_reaction`/`message_comment`/
+`tournament_class`/`tournament`/`lineup_lock`/`organization_invitation`/
+`tournament_round_participant_flag_plant` ER RLS-beskyttet
+(`org_isolation`-policy, se ENABLE ROW LEVEL SECURITY-arrayene i 001/003/
+012/013/040/055/059/070) -- disse MÅ behandles i en EGEN
+`org_connection(org_id)`-transaksjon PER berørte org, det finnes ingen
+atomisk vei på tvers (samme N+1-per-org-mønster som allerede er etablert
+praksis i `/auth/me`/"Mine runder", se kommentar i app/routers/auth.py sin
+`me()`-funksjon). **Denne grensen ble faktisk feil-klassifisert første
+gang** (seks tabeller endte opp i _merge_global sin plain_connection()-
+løkke) -- symptomet var IKKE en stille RLS-nullstilling som forventet, men
+et forsinket "invalid uuid ''"-krasj på en GJENBRUKT tilkobling (Postgres
+sin current_setting('app.current_org', true) kan returnere tom streng,
+ikke NULL, på en tilkobling der GUC-en tidligere har vært satt i samme
+fysiske forbindelse -- se ADR-080). Rettet ved faktisk å verifisere HVER
+tabell mot ENABLE ROW LEVEL SECURITY-arrayene i stedet for å anta ut fra
+tabellnavn/-mønster.
+
+Ingen sesjons-tilbakekallingsmekanisme finnes andre steder i appen --
+sletting av `app_user`-raden for taperen er derfor det ENESTE som kan tvinge
+ut en gjenlevende øktinformasjonskapsel, og skjer derfor alltid SIST, kun
+hvis alt foregående lyktes.
+"""
+
+from __future__ import annotations
+
+from pydantic import BaseModel
+
+from .db import org_connection, plain_connection
+
+_ROLE_RANK = {"owner": 3, "admin": 2, "member": 1}
+
+
+# ---------------------------------------------------------------------------
+# Forhåndsvisning (les-only) -- gjenbrukt BÅDE ved rå forespørsel (før
+# e-post sendes) og på nytt når bekreftelseslenken åpnes (fersk, i tilfelle
+# noe endret seg i mellomtiden). Pydantic (ikke dataclass) siden disse
+# returneres direkte som response_model, samme mønster som
+# app/hole_history.py sin HoleHistoryOut.
+# ---------------------------------------------------------------------------
+
+
+class OrgMembershipPreview(BaseModel):
+ organization_id: str
+ organization_name: str
+ keeper_role: str | None
+ loser_role: str | None
+ resulting_role: str
+
+
+class OrgPlayerPreview(BaseModel):
+ organization_id: str
+ organization_name: str
+ keeper_has_player: bool
+ loser_has_player: bool
+ # Antall team_roster/tournament_registration/tournament_participant/
+ # order_of_merit_team_member-rader som ville kollidert (begge kontoer
+ # allerede i samme lag/turnering/OOM-lag) -- kun >0 når BEGGE har en
+ # player-rad i denne org-en.
+ colliding_registrations: int
+
+
+class MergePreview(BaseModel):
+ keeper_id: str
+ keeper_display_name: str
+ keeper_email: str | None
+ loser_id: str
+ loser_display_name: str
+ loser_email: str | None
+ organization_memberships: list[OrgMembershipPreview] = []
+ player_links: list[OrgPlayerPreview] = []
+ friend_count: int = 0
+ shared_friend_count: int = 0
+ were_friends_with_each_other: bool = False
+ round_count: int = 0
+ secondary_email_count: int = 0
+
+
+async def _affected_org_ids(keeper_id: str, loser_id: str) -> list[str]:
+ """Union av org-er der EN AV kontoene har medlemskap ELLER en
+ spiller-kobling (en bruker kan være spiller i en org uavhengig av
+ medlemskap, se ADR-031). `player_organizations_for_user` er en
+ SECURITY DEFINER-bro (migrasjon 015) -- fungerer korrekt selv uten
+ app.current_org satt, samme som i /auth/me."""
+ org_ids: set[str] = set()
+ async with plain_connection() as conn:
+ rows = await conn.fetch(
+ "SELECT organization_id::text AS organization_id FROM organization_membership WHERE user_id = ANY($1::uuid[])",
+ [keeper_id, loser_id],
+ )
+ org_ids.update(r["organization_id"] for r in rows)
+ for uid in (keeper_id, loser_id):
+ rows = await conn.fetch(
+ "SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)", uid
+ )
+ org_ids.update(r["organization_id"] for r in rows)
+ return sorted(org_ids)
+
+
+async def compute_merge_preview(keeper_id: str, loser_id: str) -> MergePreview:
+ async with plain_connection() as conn:
+ keeper_row = await conn.fetchrow(
+ "SELECT display_name, email::text AS email FROM app_user WHERE id = $1", keeper_id
+ )
+ loser_row = await conn.fetchrow(
+ "SELECT display_name, email::text AS email FROM app_user WHERE id = $1", loser_id
+ )
+ preview = MergePreview(
+ keeper_id=keeper_id,
+ keeper_display_name=keeper_row["display_name"],
+ keeper_email=keeper_row["email"],
+ loser_id=loser_id,
+ loser_display_name=loser_row["display_name"],
+ loser_email=loser_row["email"],
+ )
+
+ membership_rows = await conn.fetch(
+ "SELECT organization_id::text AS organization_id, user_id::text AS user_id, role "
+ "FROM organization_membership WHERE user_id = ANY($1::uuid[])",
+ [keeper_id, loser_id],
+ )
+ friend_count = await conn.fetchval(
+ "SELECT count(*) FROM friendship WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1)",
+ loser_id,
+ )
+ preview.friend_count = friend_count
+
+ keeper_friend_ids = {
+ r["other"]
+ for r in await conn.fetch(
+ """
+ SELECT CASE WHEN requester_user_id = $1 THEN addressee_user_id ELSE requester_user_id END::text AS other
+ FROM friendship WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1)
+ """,
+ keeper_id,
+ )
+ }
+ loser_friend_ids = {
+ r["other"]
+ for r in await conn.fetch(
+ """
+ SELECT CASE WHEN requester_user_id = $1 THEN addressee_user_id ELSE requester_user_id END::text AS other
+ FROM friendship WHERE status = 'accepted' AND (requester_user_id = $1 OR addressee_user_id = $1)
+ """,
+ loser_id,
+ )
+ }
+ preview.shared_friend_count = len(keeper_friend_ids & loser_friend_ids)
+ preview.were_friends_with_each_other = loser_id in keeper_friend_ids
+
+ preview.round_count = await conn.fetchval(
+ "SELECT count(*) FROM round_participant WHERE user_id = $1", loser_id
+ )
+ preview.secondary_email_count = await conn.fetchval(
+ "SELECT count(*) FROM user_secondary_email WHERE user_id = $1", loser_id
+ )
+
+ membership_by_org: dict[str, dict[str, str]] = {}
+ for row in membership_rows:
+ membership_by_org.setdefault(row["organization_id"], {})[row["user_id"]] = row["role"]
+
+ org_ids = await _affected_org_ids(keeper_id, loser_id)
+ for org_id in org_ids:
+ async with org_connection(org_id) as conn:
+ org_name = await conn.fetchval("SELECT name FROM organization WHERE id = $1", org_id)
+
+ roles = membership_by_org.get(org_id, {})
+ keeper_role = roles.get(keeper_id)
+ loser_role = roles.get(loser_id)
+ if keeper_role or loser_role:
+ resulting = keeper_role or loser_role
+ if keeper_role and loser_role:
+ resulting = keeper_role if _ROLE_RANK[keeper_role] >= _ROLE_RANK[loser_role] else loser_role
+ preview.organization_memberships.append(
+ OrgMembershipPreview(
+ organization_id=org_id,
+ organization_name=org_name,
+ keeper_role=keeper_role,
+ loser_role=loser_role,
+ resulting_role=resulting,
+ )
+ )
+
+ keeper_player_id = await conn.fetchval(
+ "SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, keeper_id
+ )
+ loser_player_id = await conn.fetchval(
+ "SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, loser_id
+ )
+ if keeper_player_id or loser_player_id:
+ colliding = 0
+ if keeper_player_id and loser_player_id:
+ colliding += await conn.fetchval(
+ """
+ SELECT count(*) FROM team_roster a
+ WHERE a.player_id = $1 AND EXISTS (
+ SELECT 1 FROM team_roster b WHERE b.team_id = a.team_id AND b.player_id = $2
+ )
+ """,
+ loser_player_id,
+ keeper_player_id,
+ )
+ colliding += await conn.fetchval(
+ """
+ SELECT count(*) FROM tournament_registration a
+ WHERE a.player_id = $1 AND EXISTS (
+ SELECT 1 FROM tournament_registration b WHERE b.tournament_id = a.tournament_id AND b.player_id = $2
+ )
+ """,
+ loser_player_id,
+ keeper_player_id,
+ )
+ colliding += await conn.fetchval(
+ """
+ SELECT count(*) FROM tournament_participant a
+ WHERE a.player_id = $1 AND EXISTS (
+ SELECT 1 FROM tournament_participant b WHERE b.tournament_id = a.tournament_id AND b.player_id = $2
+ )
+ """,
+ loser_player_id,
+ keeper_player_id,
+ )
+ colliding += await conn.fetchval(
+ """
+ SELECT count(*) FROM order_of_merit_team_member a
+ WHERE a.player_id = $1 AND EXISTS (
+ SELECT 1 FROM order_of_merit_team_member b
+ WHERE b.order_of_merit_team_id = a.order_of_merit_team_id AND b.player_id = $2
+ )
+ """,
+ loser_player_id,
+ keeper_player_id,
+ )
+ preview.player_links.append(
+ OrgPlayerPreview(
+ organization_id=org_id,
+ organization_name=org_name,
+ keeper_has_player=keeper_player_id is not None,
+ loser_has_player=loser_player_id is not None,
+ colliding_registrations=colliding,
+ )
+ )
+
+ return preview
+
+
+# ---------------------------------------------------------------------------
+# Utførelse -- se modul-docstring for transaksjons-strukturen.
+# ---------------------------------------------------------------------------
+
+
+class MergeResult(BaseModel):
+ keeper_id: str
+ merged_organization_count: int
+ merged_player_count: int
+
+
+async def _merge_org_scoped(conn, org_id: str, keeper_id: str, loser_id: str) -> None:
+ """Kjøres inni ÉN org_connection(org_id)-transaksjon -- player-
+ sammenslåing + dedup av de fire tabellene som har egne UNIQUE(x,
+ player_id)-constraints. Idempotent: sjekker at taperens player-rad
+ faktisk fortsatt finnes før den gjør noe."""
+ keeper_player_id = await conn.fetchval(
+ "SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, keeper_id
+ )
+ loser_player_id = await conn.fetchval(
+ "SELECT id::text AS id FROM player WHERE organization_id = $1 AND user_id = $2", org_id, loser_id
+ )
+ if loser_player_id is None:
+ return
+
+ if keeper_player_id is None:
+ # Ingen kollisjon mulig -- enkel re-peking.
+ await conn.execute("UPDATE player SET user_id = $1 WHERE id = $2", keeper_id, loser_player_id)
+ return
+
+ # Begge har en player-rad i denne org-en: re-pek taperens avhengigheter
+ # til keeperens player-rad, men KUN der det ikke ville brutt en
+ # UNIQUE(x, player_id) -- i så fall er taperens rad en ren duplikat og
+ # slettes i stedet (personen var allerede med via keeperens rad).
+ for table, group_col in (
+ ("team_roster", "team_id"),
+ ("tournament_registration", "tournament_id"),
+ ("tournament_participant", "tournament_id"),
+ ):
+ await conn.execute(
+ f"DELETE FROM {table} a WHERE a.player_id = $1 AND EXISTS "
+ f"(SELECT 1 FROM {table} b WHERE b.{group_col} = a.{group_col} AND b.player_id = $2)",
+ loser_player_id,
+ keeper_player_id,
+ )
+ await conn.execute(f"UPDATE {table} SET player_id = $1 WHERE player_id = $2", keeper_player_id, loser_player_id)
+
+ await conn.execute(
+ "DELETE FROM order_of_merit_team_member a WHERE a.player_id = $1 AND EXISTS "
+ "(SELECT 1 FROM order_of_merit_team_member b WHERE b.order_of_merit_team_id = a.order_of_merit_team_id AND b.player_id = $2)",
+ loser_player_id,
+ keeper_player_id,
+ )
+ await conn.execute(
+ "UPDATE order_of_merit_team_member SET player_id = $1 WHERE player_id = $2", keeper_player_id, loser_player_id
+ )
+
+ # Taperens nå-tomme player-rad slettes til slutt (ingenting refererer
+ # den lenger).
+ await conn.execute("DELETE FROM player WHERE id = $1", loser_player_id)
+
+
+async def _repoint_rls_scoped_safe_tables(conn, keeper_id: str, loser_id: str) -> None:
+ """Kjøres inni SAMME org_connection(org_id)-transaksjon som
+ _merge_org_scoped, for den ene org-en av gangen. Disse henger av
+ app_user DIREKTE (ikke via player), men ER likevel RLS-beskyttet
+ (org_isolation, se 001/003/012/013-migrasjonenes ENABLE ROW LEVEL
+ SECURITY-arrayer) -- MÅ derfor IKKE stå i _merge_global sin
+ plain_connection()-løkke (en tidligere feil gjorde nettopp det, se
+ ADR-080). RLS filtrerer automatisk til akkurat DENNE org-en, så ingen
+ egen WHERE organization_id-betingelse trengs her."""
+ for table, column in (
+ ("tournament", "created_by"),
+ ("lineup_lock", "locked_by"),
+ ("organization_invitation", "invited_by"),
+ ("message", "author_user_id"),
+ ("message_comment", "author_user_id"),
+ ("tournament_round_participant_flag_plant", "planted_by_user_id"),
+ ):
+ await conn.execute(f"UPDATE {table} SET {column} = $1 WHERE {column} = $2", keeper_id, loser_id)
+
+ # message_reaction: egen UNIQUE(message_id, user_id) -- samme
+ # behold-keeperens-ellers-overfør-taperens-regel som round_message_
+ # reaction (som IKKE er RLS-beskyttet og derfor håndteres i _merge_global).
+ await conn.execute(
+ "DELETE FROM message_reaction a WHERE a.user_id = $1 AND EXISTS "
+ "(SELECT 1 FROM message_reaction b WHERE b.message_id = a.message_id AND b.user_id = $2)",
+ loser_id,
+ keeper_id,
+ )
+ await conn.execute("UPDATE message_reaction SET user_id = $1 WHERE user_id = $2", keeper_id, loser_id)
+
+
+async def _merge_global(conn, keeper_id: str, loser_id: str) -> None:
+ """Kjøres inni ÉN plain_connection()-transaksjon -- alt som IKKE er
+ RLS-beskyttet. Idempotent: sjekker taperens app_user-rad fortsatt
+ finnes før den gjør noe (i praksis alltid sann her, ekstra forsvar mot
+ dobbel-kjøring)."""
+ still_exists = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM app_user WHERE id = $1)", loser_id)
+ if not still_exists:
+ return
+
+ # organization_membership: behold høyest rolle, aldri nedgrader keeper.
+ loser_memberships = await conn.fetch(
+ "SELECT organization_id::text AS organization_id, role FROM organization_membership WHERE user_id = $1",
+ loser_id,
+ )
+ for row in loser_memberships:
+ keeper_role = await conn.fetchval(
+ "SELECT role FROM organization_membership WHERE organization_id = $1 AND user_id = $2",
+ row["organization_id"],
+ keeper_id,
+ )
+ if keeper_role is None:
+ await conn.execute(
+ "UPDATE organization_membership SET user_id = $1 WHERE organization_id = $2 AND user_id = $3",
+ keeper_id,
+ row["organization_id"],
+ loser_id,
+ )
+ elif _ROLE_RANK[row["role"]] > _ROLE_RANK[keeper_role]:
+ await conn.execute(
+ "UPDATE organization_membership SET role = $1 WHERE organization_id = $2 AND user_id = $3",
+ row["role"],
+ row["organization_id"],
+ keeper_id,
+ )
+ await conn.execute(
+ "DELETE FROM organization_membership WHERE organization_id = $1 AND user_id = $2",
+ row["organization_id"],
+ loser_id,
+ )
+ else:
+ await conn.execute(
+ "DELETE FROM organization_membership WHERE organization_id = $1 AND user_id = $2",
+ row["organization_id"],
+ loser_id,
+ )
+
+ # friendship: selv-lenke (var venner med hverandre) slettes -- ville
+ # brutt CHECK(requester <> addressee) hvis re-pekt. Delt tredjepart:
+ # behold 'accepted' fremfor 'pending', dropp taperens duplikat.
+ await conn.execute(
+ "DELETE FROM friendship WHERE (requester_user_id = $1 AND addressee_user_id = $2) "
+ "OR (requester_user_id = $2 AND addressee_user_id = $1)",
+ keeper_id,
+ loser_id,
+ )
+ loser_friendships = await conn.fetch(
+ "SELECT id::text AS id, requester_user_id::text AS requester_user_id, "
+ "addressee_user_id::text AS addressee_user_id, status "
+ "FROM friendship WHERE requester_user_id = $1 OR addressee_user_id = $1",
+ loser_id,
+ )
+ for row in loser_friendships:
+ other_id = row["addressee_user_id"] if row["requester_user_id"] == loser_id else row["requester_user_id"]
+ existing = await conn.fetchrow(
+ "SELECT id::text AS id, status FROM friendship WHERE "
+ "(requester_user_id = $1 AND addressee_user_id = $2) OR (requester_user_id = $2 AND addressee_user_id = $1)",
+ keeper_id,
+ other_id,
+ )
+ if existing is None:
+ new_requester = keeper_id if row["requester_user_id"] == loser_id else other_id
+ new_addressee = other_id if row["requester_user_id"] == loser_id else keeper_id
+ await conn.execute(
+ "UPDATE friendship SET requester_user_id = $1, addressee_user_id = $2 WHERE id = $3",
+ new_requester,
+ new_addressee,
+ row["id"],
+ )
+ elif existing["status"] != "accepted" and row["status"] == "accepted":
+ await conn.execute("UPDATE friendship SET status = 'accepted', responded_at = now() WHERE id = $1", existing["id"])
+ await conn.execute("DELETE FROM friendship WHERE id = $1", row["id"])
+ else:
+ await conn.execute("DELETE FROM friendship WHERE id = $1", row["id"])
+
+ # friend_categorization: samme selvlenke-fjerning, deretter slå sammen
+ # kategori-sett for delte venner (union, ikke overskriving).
+ await conn.execute(
+ "DELETE FROM friend_categorization WHERE (owner_user_id = $1 AND friend_user_id = $2) "
+ "OR (owner_user_id = $2 AND friend_user_id = $1)",
+ keeper_id,
+ loser_id,
+ )
+ await conn.execute(
+ """
+ INSERT INTO friend_categorization (owner_user_id, friend_user_id, category)
+ SELECT $1, friend_user_id, category FROM friend_categorization WHERE owner_user_id = $2
+ ON CONFLICT DO NOTHING
+ """,
+ keeper_id,
+ loser_id,
+ )
+ await conn.execute("DELETE FROM friend_categorization WHERE owner_user_id = $1", loser_id)
+ await conn.execute(
+ """
+ UPDATE friend_categorization SET friend_user_id = $1
+ WHERE friend_user_id = $2 AND NOT EXISTS (
+ SELECT 1 FROM friend_categorization x
+ WHERE x.owner_user_id = friend_categorization.owner_user_id AND x.friend_user_id = $1 AND x.category = friend_categorization.category
+ )
+ """,
+ keeper_id,
+ loser_id,
+ )
+ await conn.execute("DELETE FROM friend_categorization WHERE friend_user_id = $1", loser_id)
+
+ # user_notification_email_pref: behold keeperens, dropp taperens duplikat.
+ await conn.execute(
+ """
+ INSERT INTO user_notification_email_pref (user_id, type)
+ SELECT $1, type FROM user_notification_email_pref WHERE user_id = $2
+ ON CONFLICT DO NOTHING
+ """,
+ keeper_id,
+ loser_id,
+ )
+
+ # round_message_reaction: behold keeperens reaksjon om den finnes,
+ # ellers overfør taperens. (message_reaction, org-lagchat-varianten, ER
+ # RLS-beskyttet -- håndteres i _repoint_rls_scoped_safe_tables i stedet.)
+ await conn.execute(
+ "DELETE FROM round_message_reaction a WHERE a.user_id = $1 AND EXISTS "
+ "(SELECT 1 FROM round_message_reaction b WHERE b.round_message_id = a.round_message_id AND b.user_id = $2)",
+ loser_id,
+ keeper_id,
+ )
+ await conn.execute("UPDATE round_message_reaction SET user_id = $1 WHERE user_id = $2", keeper_id, loser_id)
+
+ # user_secondary_email: taperens PRIMÆRE e-post blir en ny sekundær-
+ # e-post på keeper (global unikhet garanterer ingen kollisjon mulig --
+ # den kan uansett bare tilhøre nøyaktig én av de to kontoene fra før).
+ loser_email = await conn.fetchval("SELECT email::text AS email FROM app_user WHERE id = $1", loser_id)
+ if loser_email is not None:
+ await conn.execute(
+ "INSERT INTO user_secondary_email (user_id, email) VALUES ($1, $2) ON CONFLICT (email) DO NOTHING",
+ keeper_id,
+ loser_email,
+ )
+ await conn.execute("UPDATE user_secondary_email SET user_id = $1 WHERE user_id = $2", keeper_id, loser_id)
+
+ # Alt annet med ingen konkurrerende unikhet -- trygt å re-peke direkte.
+ # KUN tabeller UTEN RLS her -- se _RLS_SCOPED_SAFE_TABLES for de som ser
+ # trygge ut på samme vis men faktisk ER org_isolation-beskyttet (player/
+ # course/tournament/message m.fl., se 001/003/012/013-arrayene) og derfor
+ # MÅ behandles i _merge_org_scoped (org_connection per org) i stedet --
+ # en tidligere feil her (alle seks re-pekt via plain_connection) ga et
+ # snodig, forsinket symptom (tom streng vs. NULL på en gjenbrukt
+ # tilkoblings app.current_org etter en avsluttet org_connection()-
+ # transaksjon) i stedet for en ren RLS-stillhet, se ADR-080 for
+ # detaljene -- IKKE anta at en tabell er RLS-fri uten å sjekke arrayene.
+ for table, column in (
+ ("handicap_history", "user_id"),
+ ("notification", "user_id"),
+ ("push_subscription", "user_id"),
+ ("personal_course", "created_by_user_id"),
+ ("round", "owner_user_id"),
+ ("round_participant", "user_id"),
+ ("round_message", "author_user_id"),
+ ("round_message_comment", "author_user_id"),
+ ("round_shot", "recorded_by_user_id"),
+ ("round_message_tag", "tagged_user_id"),
+ ("round_message_comment_tag", "tagged_user_id"),
+ ("round_participant_flag_plant", "planted_by_user_id"),
+ ):
+ await conn.execute(f"UPDATE {table} SET {column} = $1 WHERE {column} = $2", keeper_id, loser_id)
+
+ # Ephemere/tokens knyttet til taperen -- ingen verdi å overføre,
+ # forsvinner uansett via CASCADE ved sletting under, men ryddes
+ # eksplisitt her for et rent svar fra denne funksjonen alene.
+ await conn.execute("DELETE FROM two_factor_code WHERE user_id = $1", loser_id)
+ await conn.execute("DELETE FROM email_change_token WHERE user_id = $1", loser_id)
+ await conn.execute("DELETE FROM secondary_email_token WHERE user_id = $1", loser_id)
+
+
+async def execute_merge(keeper_id: str, loser_id: str) -> MergeResult:
+ org_ids = await _affected_org_ids(keeper_id, loser_id)
+
+ async with plain_connection() as conn:
+ async with conn.transaction():
+ await _merge_global(conn, keeper_id, loser_id)
+
+ merged_player_count = 0
+ for org_id in org_ids:
+ async with org_connection(org_id) as conn:
+ had_loser_player = await conn.fetchval(
+ "SELECT EXISTS(SELECT 1 FROM player WHERE organization_id = $1 AND user_id = $2)", org_id, loser_id
+ )
+ await _merge_org_scoped(conn, org_id, keeper_id, loser_id)
+ await _repoint_rls_scoped_safe_tables(conn, keeper_id, loser_id)
+ if had_loser_player:
+ merged_player_count += 1
+
+ # Siste, irreversible steg -- kun etter at alt over har lyktes.
+ # CASCADE dekker det gjenværende (account_merge_token selv, samt
+ # ethvert FK utenfor listen over som ikke er berørt her).
+ async with plain_connection() as conn:
+ await conn.execute("DELETE FROM app_user WHERE id = $1", loser_id)
+
+ return MergeResult(
+ keeper_id=keeper_id,
+ merged_organization_count=len(org_ids),
+ merged_player_count=merged_player_count,
+ )
diff --git a/app/auth.py b/app/auth.py
index da0c870..da23f6e 100644
--- a/app/auth.py
+++ b/app/auth.py
@@ -229,6 +229,23 @@ async def user_requires_2fa_enrollment(conn, user_id: str) -> bool:
)
+async def resolve_user_id_by_email(conn, email: str) -> str | None:
+ """Løser en e-postadresse til eierens app_user.id -- sjekker PRIMÆR
+ adresse (app_user.email) først, deretter SEKUNDÆR (user_secondary_
+ email, FEATURE_BACKLOG.md "flere e-postadresser"/ADR-032). Returnerer
+ None hvis ingen konto eier adressen i det hele tatt -- kalleren
+ avgjør selv hva det betyr (opprett ny konto ved innlogging, avvis
+ passord-innlogging, eller "finnes ikke, kan ikke slås sammen med" ved
+ kontosammenslåing). Faktorert ut 2026-08-16 (kontosammenslåing) fra to
+ tidligere identiske kopier i verify_magic_link/login_with_password."""
+ user_id = await conn.fetchval("SELECT id::text AS id FROM app_user WHERE email = $1", email)
+ if user_id is not None:
+ return user_id
+ return await conn.fetchval(
+ "SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email
+ )
+
+
async def get_superadmin_user(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""ADR-022 Beslutning D: is_super_admin er KUN manuelt DB-tildelt, aldri
settbart via noe API-endepunkt. Denne avhengigheten sjekker bare
diff --git a/app/email.py b/app/email.py
index 2561012..ba9a560 100644
--- a/app/email.py
+++ b/app/email.py
@@ -294,6 +294,64 @@ async def send_secondary_email_verification(to_email: str, raw_token: str, local
await to_thread(_send_sync, to_email, template["subject"], body)
+_ACCOUNT_MERGE_TEMPLATES = {
+ "nb": {
+ "subject": "Bekreft kontosammenslåing på TeeCup",
+ "body": (
+ "Hei,\n\n"
+ "{initiator_name} ({initiator_email}) har bedt om å slå SIN "
+ "TeeCup-konto sammen med DENNE kontoen. Alt fra denne kontoen "
+ "(organisasjoner, spiller-koblinger, runder, venner) flyttes "
+ "over til den andre kontoen, og denne kontoen slettes deretter "
+ "-- dette kan IKKE angres.\n\n"
+ "Åpne lenken under innen {minutes} minutter for å se nøyaktig "
+ "hva som slås sammen og bekrefte:\n\n"
+ "{link}\n\n"
+ "Ba du (eller noen andre) ikke om dette selv, kan du se bort "
+ "fra e-posten -- ingenting slås sammen uten at lenken åpnes OG "
+ "bekreftes eksplisitt.\n"
+ ),
+ },
+ "en": {
+ "subject": "Confirm account merge on TeeCup",
+ "body": (
+ "Hi,\n\n"
+ "{initiator_name} ({initiator_email}) has requested to merge "
+ "THEIR TeeCup account with THIS account. Everything from this "
+ "account (organizations, player links, rounds, friends) will "
+ "move to the other account, and this account will then be "
+ "deleted -- this CANNOT be undone.\n\n"
+ "Open the link below within {minutes} minutes to see exactly "
+ "what will be merged and confirm:\n\n"
+ "{link}\n\n"
+ "If you (or someone else) didn't request this yourself, you "
+ "can ignore this email -- nothing is merged unless the link is "
+ "opened AND explicitly confirmed.\n"
+ ),
+ },
+}
+
+
+async def send_account_merge_confirmation(
+ to_email: str, raw_token: str, initiator_name: str, initiator_email: str, locale: str = "nb"
+) -> None:
+ """Kontosammenslåing ("Del 2" av flere-e-postadresser, ADR-080) --
+ sendes til MÅL-kontoens (taperens) e-post, beviser eierskap av DEN
+ kontoen (ikke bare at initiativtakeren fortsatt er innlogget), samme
+ prinsipp som send_email_change_confirmation. Selve utførelsen skjer
+ IKKE ved lenke-åpning alene -- kun en forhåndsvisning vises, en
+ eksplisitt bekreftelse kreves i tillegg (se app/account_merge.py)."""
+ template = _ACCOUNT_MERGE_TEMPLATES.get(locale, _ACCOUNT_MERGE_TEMPLATES["nb"])
+ link = f"{settings.PUBLIC_BASE_URL}/kontosammenslaing/{raw_token}"
+ body = template["body"].format(
+ minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES,
+ link=link,
+ initiator_name=initiator_name,
+ initiator_email=initiator_email,
+ )
+ await to_thread(_send_sync, to_email, template["subject"], body)
+
+
_NOTIFICATION_TEMPLATES = {
"nb": {
"subject": "Nytt varsel fra TeeCup",
diff --git a/app/main.py b/app/main.py
index 61e297e..8c5aadd 100644
--- a/app/main.py
+++ b/app/main.py
@@ -16,6 +16,7 @@ from fastapi import FastAPI
from .db import init_pool, close_pool
from .routers import (
+ account_merge,
auth,
courses,
friends,
@@ -48,6 +49,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="TeeCup API", lifespan=lifespan)
app.include_router(auth.router)
+app.include_router(account_merge.router)
app.include_router(organizations.router)
app.include_router(players.router)
app.include_router(courses.router)
diff --git a/app/routers/account_merge.py b/app/routers/account_merge.py
new file mode 100644
index 0000000..43296ce
--- /dev/null
+++ b/app/routers/account_merge.py
@@ -0,0 +1,135 @@
+"""
+Kontosammenslåing ("Del 2" av flere-e-postadresser, ADR-080) -- selvbetjent:
+initiativtakeren (en aktiv økt) ber om å slå en ANNEN, egen konto sammen med
+den de er logget inn på. Krever bevis for eierskap av BEGGE kontoer: den
+aktive økten beviser eierskap av initiativtakerens konto, en lenke sendt til
+MÅL-kontoens e-post (samme token-i-lenke-mønster som ADR-032 sin e-postbytte)
+beviser eierskap av den andre.
+
+Selve beregningen/utførelsen ligger i app/account_merge.py (ren, uten
+FastAPI-avhengigheter) -- denne filen er kun HTTP-laget: validering,
+token-håndtering, e-postutsending.
+"""
+
+import secrets
+import traceback
+from datetime import datetime, timedelta, timezone
+
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, EmailStr
+
+from ..account_merge import MergePreview, MergeResult, compute_merge_preview, execute_merge
+from ..auth import CurrentUser, get_current_user, resolve_user_id_by_email
+from ..config import settings
+from ..db import plain_connection
+from ..email import send_account_merge_confirmation
+from ..errors import app_error
+from .auth import _hash_secret
+
+router = APIRouter(prefix="/auth/account-merge", tags=["account-merge"])
+
+
+class AccountMergeEmailRequest(BaseModel):
+ email: EmailStr
+
+
+async def _resolve_target_or_error(conn, requester_id: str, email: str) -> str:
+ target_id = await resolve_user_id_by_email(conn, email)
+ if target_id is None:
+ raise app_error(404, "NOT_FOUND", "Fant ingen konto med denne e-posten.")
+ if target_id == requester_id:
+ raise app_error(400, "VALIDATION_FAILED", "Dette er allerede din egen konto.")
+ return target_id
+
+
+@router.post("/preview", response_model=MergePreview)
+async def preview_account_merge(
+ body: AccountMergeEmailRequest, user: CurrentUser = Depends(get_current_user)
+) -> MergePreview:
+ async with plain_connection() as conn:
+ target_id = await _resolve_target_or_error(conn, user.user_id, body.email.lower())
+ return await compute_merge_preview(user.user_id, target_id)
+
+
+@router.post("/request")
+async def request_account_merge(
+ body: AccountMergeEmailRequest, user: CurrentUser = Depends(get_current_user)
+) -> dict:
+ email = body.email.lower()
+ async with plain_connection() as conn:
+ target_id = await _resolve_target_or_error(conn, user.user_id, email)
+ target_row = await conn.fetchrow(
+ "SELECT email::text AS email, display_name FROM app_user WHERE id = $1", target_id
+ )
+ initiator_row = await conn.fetchrow(
+ "SELECT display_name, email::text AS email FROM app_user WHERE id = $1", user.user_id
+ )
+
+ 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 account_merge_token (initiator_user_id, target_user_id, token_hash, expires_at) "
+ "VALUES ($1, $2, $3, $4)",
+ user.user_id,
+ target_id,
+ _hash_secret(raw_token),
+ expires_at,
+ )
+
+ # Lenken sendes til MÅL-e-posten som faktisk ble oppgitt (kan være
+ # taperens primær- ELLER en av dens sekundæradresser) -- beviser
+ # eierskap av akkurat DEN adressen, tilstrekkelig siden begge allerede
+ # er verifiserte adresser på taper-kontoen.
+ if settings.DEV_LOG_MAGIC_LINKS:
+ print(f"[DEV] Kontosammenslåing-lenke for {email}: {raw_token}", flush=True)
+ elif settings.SMTP_CONFIGURED:
+ try:
+ await send_account_merge_confirmation(
+ email, raw_token, initiator_row["display_name"], initiator_row["email"] or ""
+ )
+ except Exception:
+ traceback.print_exc()
+ return {"status": "ok", "detail": "Hvis e-posten tilhører en annen konto, er en bekreftelseslenke sendt."}
+
+
+@router.get("/token/{token}", response_model=MergePreview)
+async def get_account_merge_token(token: str) -> MergePreview:
+ """Validerer token (IKKE konsumert/utløpt) og returnerer en FERSK
+ forhåndsvisning -- konsumerer IKKE token, kun /confirm gjør det. Ingen
+ sesjon påkrevd (samme som e-postbytte-bekreftelse, kan åpnes på en
+ annen enhet enn den som ba om sammenslåingen)."""
+ token_hash = _hash_secret(token)
+ async with plain_connection() as conn:
+ token_row = await conn.fetchrow(
+ "SELECT initiator_user_id::text AS initiator_user_id, target_user_id::text AS target_user_id "
+ "FROM account_merge_token WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()",
+ token_hash,
+ )
+ if token_row is None:
+ raise app_error(401, "INVALID_MERGE_LINK", "Lenken er ugyldig, brukt eller utløpt.")
+ return await compute_merge_preview(token_row["initiator_user_id"], token_row["target_user_id"])
+
+
+class AccountMergeConfirm(BaseModel):
+ token: str
+
+
+@router.post("/confirm", response_model=MergeResult)
+async def confirm_account_merge(body: AccountMergeConfirm) -> MergeResult:
+ token_hash = _hash_secret(body.token)
+ async with plain_connection() as conn:
+ # Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to
+ # samtidige bekreftelser av samme lenke kan da ikke begge lykkes
+ # (samme prinsipp som magic-link/e-postbytte).
+ token_row = await conn.fetchrow(
+ """
+ UPDATE account_merge_token
+ SET consumed_at = now()
+ WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
+ RETURNING initiator_user_id::text AS initiator_user_id, target_user_id::text AS target_user_id
+ """,
+ token_hash,
+ )
+ if token_row is None:
+ raise app_error(401, "INVALID_MERGE_LINK", "Lenken er ugyldig, brukt eller utløpt.")
+ return await execute_merge(token_row["initiator_user_id"], token_row["target_user_id"])
diff --git a/app/routers/auth.py b/app/routers/auth.py
index 1c7e882..becf8bd 100644
--- a/app/routers/auth.py
+++ b/app/routers/auth.py
@@ -57,6 +57,7 @@ from ..auth import (
get_current_user,
get_pending_user,
hash_password,
+ resolve_user_id_by_email,
should_use_secure_cookies,
totp_provisioning_uri,
user_requires_2fa_enrollment,
@@ -312,14 +313,13 @@ 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"]
- # 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
- )
+ # Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere
+ # e-postadresser") løses til EIERENS eksisterende konto -- må
+ # sjekkes FØR en eventuell ny konto opprettes 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.
+ resolved_user_id = await resolve_user_id_by_email(conn, email)
# Driftsvarsel (2026-07-30, reist av bruker): mottakeren + selve
# kontoens navn trengs ETTER at connection-blokken er lukket
# (e-postutsending skal aldri skje mens en transaksjon/tilkobling
@@ -327,8 +327,8 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request:
is_new_account = False
new_account_display_name = None
- if secondary_owner_id is not None:
- user_id = secondary_owner_id
+ if resolved_user_id is not None:
+ user_id = resolved_user_id
else:
placeholder_name = email.split("@")[0]
new_account_display_name = body.display_name or placeholder_name
@@ -397,20 +397,17 @@ async def login_with_password(
) -> LoginResult:
email = body.email.lower()
async with plain_connection() as conn:
- 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
+ # Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere
+ # e-postadresser") -- passordet ligger uansett på selve kontoen,
+ # ikke adressen, så slå opp eieren og fortsett normalt.
+ resolved_user_id = await resolve_user_id_by_email(conn, email)
+ user_row = (
+ await conn.fetchrow(
+ "SELECT id::text AS id, password_hash FROM app_user WHERE id = $1", resolved_user_id
)
- 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
- )
+ if resolved_user_id is not None
+ else None
+ )
# Identisk feil uansett årsak (e-post finnes ikke / intet passord satt /
# feil passord) -- unngår enumerering, samme prinsipp som magic-link.
if (
@@ -1185,8 +1182,9 @@ async def request_secondary_email(
raise app_error(
409,
"DUPLICATE",
- "Denne adressen er allerede en konto sin hovedadresse. Ekte "
- "konto-sammenslåing støttes ikke ennå.",
+ "Denne adressen er allerede en konto sin hovedadresse. Bruk "
+ "\"Slå sammen med en annen konto\" lenger ned for å slå de to "
+ "kontoene sammen i stedet.",
)
existing_secondary = await conn.fetchval(
"SELECT user_id::text FROM user_secondary_email WHERE email = $1", email
diff --git a/frontend/app/kontosammenslaing/[token]/page.tsx b/frontend/app/kontosammenslaing/[token]/page.tsx
new file mode 100644
index 0000000..0cafd52
--- /dev/null
+++ b/frontend/app/kontosammenslaing/[token]/page.tsx
@@ -0,0 +1,21 @@
+import { Wordmark } from "@/components/wordmark"
+import { AccountMergeConfirmForm } from "@/components/account-merge-confirm-form"
+
+export default async function AccountMergeConfirmPage({
+ params,
+}: {
+ params: Promise<{ token: string }>
+}) {
+ const { token } = await params
+ return (
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/components/account-merge-confirm-form.tsx b/frontend/components/account-merge-confirm-form.tsx
new file mode 100644
index 0000000..7f67e23
--- /dev/null
+++ b/frontend/components/account-merge-confirm-form.tsx
@@ -0,0 +1,225 @@
+"use client"
+
+// Kontosammenslåing ("Del 2", ADR-080) -- bekreftelsessiden lenken sendt til
+// MÅL-kontoens (taperens) e-post peker til. INGEN sesjon kreves (kan åpnes
+// på en annen enhet enn den som ba om sammenslåingen, samme prinsipp som
+// e-postbytte-bekreftelse) -- selve token-en ER beviset på eierskap.
+//
+// To steg, ALDRI automatisk: 1) hent en FERSK forhåndsvisning ved lasting
+// (les-only, konsumerer ikke token), 2) krev et eksplisitt "Bekreft
+// sammenslåing"-trykk før selve utførelsen -- en lenke skal aldri kunne
+// utløse en irreversibel handling bare ved å bli åpnet.
+
+import { useEffect, useState } from "react"
+import Link from "next/link"
+import { AlertTriangle, CheckCircle2, Merge, XCircle } from "lucide-react"
+import { Button } from "@/components/ui/button"
+
+type Status = "loading" | "ready" | "confirming" | "success" | "error"
+
+type ApiOrgMembershipPreview = {
+ organization_id: string
+ organization_name: string
+ keeper_role: string | null
+ loser_role: string | null
+ resulting_role: string
+}
+
+type ApiOrgPlayerPreview = {
+ organization_id: string
+ organization_name: string
+ keeper_has_player: boolean
+ loser_has_player: boolean
+ colliding_registrations: number
+}
+
+type ApiMergePreview = {
+ keeper_id: string
+ keeper_display_name: string
+ keeper_email: string | null
+ loser_id: string
+ loser_display_name: string
+ loser_email: string | null
+ organization_memberships: ApiOrgMembershipPreview[]
+ player_links: ApiOrgPlayerPreview[]
+ friend_count: number
+ shared_friend_count: number
+ were_friends_with_each_other: boolean
+ round_count: number
+ secondary_email_count: number
+}
+
+const ROLE_LABELS: Record = { owner: "eier", admin: "administrator", member: "medlem" }
+
+export function AccountMergeConfirmForm({ token }: { token: string }) {
+ const [status, setStatus] = useState("loading")
+ const [preview, setPreview] = useState(null)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ let cancelled = false
+ async function load() {
+ try {
+ const res = await fetch(`/auth/account-merge/token/${token}`, { credentials: "include" })
+ if (!res.ok) {
+ const body = await res.json().catch(() => null)
+ throw new Error(body?.detail?.message ?? "Lenken er ugyldig, brukt eller utløpt.")
+ }
+ const data: ApiMergePreview = await res.json()
+ if (!cancelled) {
+ setPreview(data)
+ setStatus("ready")
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
+ setStatus("error")
+ }
+ }
+ }
+ void load()
+ return () => {
+ cancelled = true
+ }
+ }, [token])
+
+ async function handleConfirm() {
+ setStatus("confirming")
+ setError(null)
+ try {
+ const res = await fetch("/auth/account-merge/confirm", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({ token }),
+ })
+ if (!res.ok) {
+ const body = await res.json().catch(() => null)
+ throw new Error(body?.detail?.message ?? "Klarte ikke å slå sammen kontoene.")
+ }
+ setStatus("success")
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
+ setStatus("ready")
+ }
+ }
+
+ if (status === "loading") {
+ return (
+
+ {preview.keeper_display_name}
+ {preview.keeper_email ? ` (${preview.keeper_email})` : ""} har bedt om å slå denne kontoen (
+ {preview.loser_display_name}
+ {preview.loser_email ? `, ${preview.loser_email}` : ""}) sammen med sin.
+
+ Dette kan ikke angres. Denne kontoen ({preview.loser_display_name}) slettes helt så snart
+ du bekrefter.
+
+
+
+
+
+ {error &&
{error}
}
+
+ )
+}
diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx
index 38e9940..5bf9990 100644
--- a/frontend/components/account-settings.tsx
+++ b/frontend/components/account-settings.tsx
@@ -20,6 +20,7 @@ import {
Lock,
LogOut,
Mail,
+ Merge,
Pencil,
Plus,
ShieldCheck,
@@ -298,6 +299,8 @@ export function AccountSettings() {
+
+
@@ -1132,11 +1135,11 @@ 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.
+// Én person, flere e-postadresser (FEATURE_BACKLOG.md "Del 1") -- kun det
+// enkle tilfellet: en FRI, ukrevd adresse legges til og verifiseres, og kan
+// deretter brukes til innlogging i tillegg til hovedadressen. Tilhører
+// adressen ALLEREDE en annen konto, avviser backend med 409 DUPLICATE og
+// henviser til AccountMergeSection under ("Del 2", ADR-080).
function SecondaryEmailSection({
secondaryEmails,
onChanged,
@@ -1278,6 +1281,217 @@ function SecondaryEmailSection({
)
}
+// --- Kontosammenslåing ("Del 2", ADR-080, 2026-08-16) -----------------------
+// Selvbetjent: bruker taster inn e-posten til en ANNEN, egen konto, ser en
+// forhåndsvisning av hva som vil bli slått sammen, og ber om en
+// bekreftelseslenke som sendes til DEN kontoens e-post (beviser eierskap av
+// den, samme prinsipp som e-postbytte). Selve bekreftelsen/utførelsen skjer
+// på en egen side (/kontosammenslaing/[token]) når lenken åpnes -- ALDRI her,
+// denne seksjonen sender kun forespørselen.
+
+type ApiOrgMembershipPreview = {
+ organization_id: string
+ organization_name: string
+ keeper_role: string | null
+ loser_role: string | null
+ resulting_role: string
+}
+
+type ApiOrgPlayerPreview = {
+ organization_id: string
+ organization_name: string
+ keeper_has_player: boolean
+ loser_has_player: boolean
+ colliding_registrations: number
+}
+
+type ApiMergePreview = {
+ keeper_id: string
+ keeper_display_name: string
+ keeper_email: string | null
+ loser_id: string
+ loser_display_name: string
+ loser_email: string | null
+ organization_memberships: ApiOrgMembershipPreview[]
+ player_links: ApiOrgPlayerPreview[]
+ friend_count: number
+ shared_friend_count: number
+ were_friends_with_each_other: boolean
+ round_count: number
+ secondary_email_count: number
+}
+
+const ROLE_LABELS: Record = { owner: "eier", admin: "administrator", member: "medlem" }
+
+function AccountMergeSection() {
+ const [email, setEmail] = useState("")
+ const [preview, setPreview] = useState(null)
+ const [loadingPreview, setLoadingPreview] = useState(false)
+ const [requesting, setRequesting] = useState(false)
+ const [sent, setSent] = useState(false)
+ const [error, setError] = useState(null)
+
+ async function handlePreview(e: React.FormEvent) {
+ e.preventDefault()
+ setError(null)
+ setLoadingPreview(true)
+ setPreview(null)
+ try {
+ const res = await fetch("/auth/account-merge/preview", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({ email: email.trim() }),
+ })
+ if (!res.ok) {
+ const body = await res.json().catch(() => null)
+ throw new Error(body?.detail?.message ?? "Fant ingen konto med denne e-posten.")
+ }
+ setPreview(await res.json())
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
+ } finally {
+ setLoadingPreview(false)
+ }
+ }
+
+ async function handleRequest() {
+ setRequesting(true)
+ setError(null)
+ try {
+ const res = await fetch("/auth/account-merge/request", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({ email: email.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 {
+ setRequesting(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
Slå sammen med en annen konto
+
+
+
+ Har du endt opp med to TeeCup-kontoer (f.eks. logget inn med to ulike
+ adresser)? Slå dem sammen til én -- alt fra den andre kontoen
+ (organisasjoner, spiller-koblinger, runder, venner) flyttes over hit.
+
+
+ {sent ? (
+
+ Sjekk innboksen til {email.trim()} -- åpne lenken der for å se
+ nøyaktig hva som slås sammen og bekrefte. Ingenting slås sammen før
+ det er bekreftet der.
+
+ ) : preview ? (
+
+
+ {preview.loser_display_name}
+ {preview.loser_email ? ` (${preview.loser_email})` : ""} slås inn i denne kontoen:
+