Kontosammenslåing, selvbetjent (ADR-080, "Del 2" av flere e-postadresser)

Selvbetjent sammenslåing av to TeeCup-kontoer: keeper (initiativtaker)
ber om sammenslåing, bekreftelseslenke sendt til taperens e-post beviser
eierskap, taperens data (org-medlemskap/spillerkoblinger/runder/venner)
flyttes over og taperens konto slettes. Ny migrasjon 077 (account_merge_
token, ikke kjørt mot ekte teecup_db ennå), ny app/account_merge.py
(N+1-transaksjoner per RLS-grensen, fullt konfliktkart), nye endepunkter
i app/routers/account_merge.py, ny frontend-seksjon i kontoinnstillinger
+ egen bekreftelsesside.

Fant og fikset en reell RLS-relatert bug via testsuiten før produksjon:
seks RLS-beskyttede tabeller var feilaktig plassert i den globale
"trygt å re-peke"-løkken, forårsaket et krasj pga. en Postgres GUC-kvirk
på pooled forbindelser. Se ADR-080 for full begrunnelse.

12 nye tester (95/95 backend totalt), full scratch-verifisert
ende-til-ende inkl. lys/mørk, ekte teecup_db urørt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-17 07:56:41 +02:00
parent cdd3719ab4
commit 7d28727cb7
13 changed files with 1792 additions and 30 deletions

42
077_account_merge.sql Normal file
View file

@ -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;

View file

@ -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) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -11754,3 +11754,60 @@ Neste steg:
**Rullet ut 2026-08-16** -- ingen migrasjon, `docker compose build **Rullet ut 2026-08-16** -- ingen migrasjon, `docker compose build
teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker. teecup_frontend && up -d` etter eksplisitt bekreftelse fra bruker.
Containeren startet rent. 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å.**

567
app/account_merge.py Normal file
View file

@ -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 behandles i en EGEN
`org_connection(org_id)`-transaksjon PER berørte org, det finnes ingen
atomisk vei 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 en GJENBRUKT tilkobling (Postgres
sin current_setting('app.current_org', true) kan returnere tom streng,
ikke NULL, 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) -- 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, 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,
)

View file

@ -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: 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 """ADR-022 Beslutning D: is_super_admin er KUN manuelt DB-tildelt, aldri
settbart via noe API-endepunkt. Denne avhengigheten sjekker bare settbart via noe API-endepunkt. Denne avhengigheten sjekker bare

View file

@ -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) 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 = { _NOTIFICATION_TEMPLATES = {
"nb": { "nb": {
"subject": "Nytt varsel fra TeeCup", "subject": "Nytt varsel fra TeeCup",

View file

@ -16,6 +16,7 @@ from fastapi import FastAPI
from .db import init_pool, close_pool from .db import init_pool, close_pool
from .routers import ( from .routers import (
account_merge,
auth, auth,
courses, courses,
friends, friends,
@ -48,6 +49,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="TeeCup API", lifespan=lifespan) app = FastAPI(title="TeeCup API", lifespan=lifespan)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(account_merge.router)
app.include_router(organizations.router) app.include_router(organizations.router)
app.include_router(players.router) app.include_router(players.router)
app.include_router(courses.router) app.include_router(courses.router)

View file

@ -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 . 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 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"])

View file

@ -57,6 +57,7 @@ from ..auth import (
get_current_user, get_current_user,
get_pending_user, get_pending_user,
hash_password, hash_password,
resolve_user_id_by_email,
should_use_secure_cookies, should_use_secure_cookies,
totp_provisioning_uri, totp_provisioning_uri,
user_requires_2fa_enrollment, 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.") 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- # Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere
# adresser") løses til EIERENS eksisterende konto -- må sjekkes FØR # e-postadresser") løses til EIERENS eksisterende konto -- må
# app_user-oppslaget under, ellers ville en innlogging på en # sjekkes FØR en eventuell ny konto opprettes under, ellers ville
# sekundær adresse (som med vilje ALDRI står i app_user.email) # en innlogging på en sekundær adresse (som med vilje ALDRI står i
# stille opprettet en helt ny, separat, feilaktig konto. # app_user.email) stille opprettet en helt ny, separat, feilaktig
secondary_owner_id = await conn.fetchval( # konto.
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email resolved_user_id = await resolve_user_id_by_email(conn, email)
)
# Driftsvarsel (2026-07-30, reist av bruker): mottakeren + selve # Driftsvarsel (2026-07-30, reist av bruker): mottakeren + selve
# kontoens navn trengs ETTER at connection-blokken er lukket # kontoens navn trengs ETTER at connection-blokken er lukket
# (e-postutsending skal aldri skje mens en transaksjon/tilkobling # (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 is_new_account = False
new_account_display_name = None new_account_display_name = None
if secondary_owner_id is not None: if resolved_user_id is not None:
user_id = secondary_owner_id user_id = resolved_user_id
else: else:
placeholder_name = email.split("@")[0] placeholder_name = email.split("@")[0]
new_account_display_name = body.display_name or placeholder_name new_account_display_name = body.display_name or placeholder_name
@ -397,20 +397,17 @@ async def login_with_password(
) -> LoginResult: ) -> LoginResult:
email = body.email.lower() email = body.email.lower()
async with plain_connection() as conn: async with plain_connection() as conn:
user_row = await conn.fetchrow( # Primær-så-sekundær-oppslag (FEATURE_BACKLOG.md "Én person, flere
"SELECT id::text AS id, password_hash FROM app_user WHERE email = $1", email # e-postadresser") -- passordet ligger uansett på selve kontoen,
) # ikke adressen, så slå opp eieren og fortsett normalt.
if user_row is None: resolved_user_id = await resolve_user_id_by_email(conn, email)
# Kan være en sekundær adresse (FEATURE_BACKLOG.md "Én person, user_row = (
# flere e-postadresser") -- passordet ligger uansett på selve await conn.fetchrow(
# kontoen, ikke adressen, så slå opp eieren og fortsett normalt. "SELECT id::text AS id, password_hash FROM app_user WHERE id = $1", resolved_user_id
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: if resolved_user_id is not None
user_row = await conn.fetchrow( else None
"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 (
@ -1185,8 +1182,9 @@ async def request_secondary_email(
raise app_error( raise app_error(
409, 409,
"DUPLICATE", "DUPLICATE",
"Denne adressen er allerede en konto sin hovedadresse. Ekte " "Denne adressen er allerede en konto sin hovedadresse. Bruk "
"konto-sammenslåing støttes ikke ennå.", "\"Slå sammen med en annen konto\" lenger ned for å slå de to "
"kontoene sammen i stedet.",
) )
existing_secondary = await conn.fetchval( existing_secondary = await conn.fetchval(
"SELECT user_id::text FROM user_secondary_email WHERE email = $1", email "SELECT user_id::text FROM user_secondary_email WHERE email = $1", email

View file

@ -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 (
<main className="flex min-h-[100dvh] flex-col items-center justify-center bg-background px-5 py-10">
<div className="flex w-full max-w-sm flex-col gap-8">
<header className="flex flex-col items-center gap-3 text-center">
<Wordmark />
</header>
<AccountMergeConfirmForm token={token} />
</div>
</main>
)
}

View file

@ -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<string, string> = { owner: "eier", admin: "administrator", member: "medlem" }
export function AccountMergeConfirmForm({ token }: { token: string }) {
const [status, setStatus] = useState<Status>("loading")
const [preview, setPreview] = useState<ApiMergePreview | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="flex w-full flex-col items-center gap-4 rounded-3xl border border-border bg-card p-8 text-center shadow-lg shadow-black/5">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
<p className="text-sm text-muted-foreground" role="status">
Henter forhåndsvisning
</p>
</div>
)
}
if (status === "success") {
return (
<div className="flex w-full flex-col items-center gap-5 rounded-3xl border border-border bg-card p-6 text-center shadow-lg shadow-black/5 sm:p-8">
<div className="flex size-16 items-center justify-center rounded-2xl bg-primary/15">
<CheckCircle2 aria-hidden="true" className="size-8 text-primary" />
</div>
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-extrabold tracking-tight text-balance">Kontoene er slått sammen</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Alt er flyttet over. Denne kontoen finnes ikke lenger -- logg inn med den kontoen du
slo sammen til.
</p>
</div>
<Link href="/logg-inn" className="text-sm font-semibold text-primary transition-colors hover:text-primary/80">
Til innlogging
</Link>
</div>
)
}
if (status === "error" || !preview) {
return (
<div className="flex w-full flex-col items-center gap-5 rounded-3xl border border-border bg-card p-6 text-center shadow-lg shadow-black/5 sm:p-8">
<div className="flex size-16 items-center justify-center rounded-2xl bg-destructive/15">
<XCircle aria-hidden="true" className="size-8 text-destructive" />
</div>
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-extrabold tracking-tight text-balance">Kunne ikke bekrefte</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{error}</p>
</div>
<Link href="/account" className="text-sm font-semibold text-primary transition-colors hover:text-primary/80">
Til kontoinnstillinger
</Link>
</div>
)
}
return (
<div className="flex w-full flex-col gap-5 rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<Merge aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<h2 className="text-xl font-extrabold tracking-tight text-balance">Bekreft kontosammenslåing</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{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.
</p>
</div>
<ul className="flex flex-col gap-1.5 rounded-2xl border border-border bg-background p-3 text-sm text-foreground">
{preview.organization_memberships.map((m) => (
<li key={m.organization_id}>
{m.organization_name}: blir {ROLE_LABELS[m.resulting_role] ?? m.resulting_role}
</li>
))}
{preview.player_links.map((p) => (
<li key={p.organization_id}>
{p.organization_name}: spiller-koblingen slås sammen
{p.colliding_registrations > 0
? ` (${p.colliding_registrations} dobbeltregistrering${p.colliding_registrations === 1 ? "" : "er"} ryddes opp)`
: ""}
</li>
))}
{preview.round_count > 0 && <li>{preview.round_count} runde{preview.round_count === 1 ? "" : "r"} flyttes over</li>}
{preview.friend_count > 0 && (
<li>
{preview.friend_count} venn{preview.friend_count === 1 ? "" : "er"} flyttes over
{preview.shared_friend_count > 0 ? ` (${preview.shared_friend_count} felles, slås sammen)` : ""}
</li>
)}
{preview.were_friends_with_each_other && <li>Dere var venner med hverandre -- fjernes ved sammenslåing</li>}
{preview.secondary_email_count > 0 && (
<li>
{preview.secondary_email_count} ekstra e-postadresse{preview.secondary_email_count === 1 ? "" : "r"} flyttes over
</li>
)}
</ul>
<div className="flex flex-col gap-3 rounded-2xl border border-destructive/40 bg-destructive/5 p-4">
<div className="flex items-start gap-2">
<AlertTriangle aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-destructive" />
<p className="text-sm font-semibold text-foreground text-pretty">
Dette kan ikke angres. Denne kontoen ({preview.loser_display_name}) slettes helt snart
du bekrefter.
</p>
</div>
<Button
type="button"
variant="destructive"
disabled={status === "confirming"}
onClick={handleConfirm}
className="min-h-12 w-full justify-center gap-2 rounded-xl text-base font-bold"
>
<Merge aria-hidden="true" className="size-5" />
{status === "confirming" ? "Slår sammen …" : "Bekreft sammenslåing"}
</Button>
</div>
{error && <p className="text-sm font-medium text-destructive text-pretty">{error}</p>}
</div>
)
}

View file

@ -20,6 +20,7 @@ import {
Lock, Lock,
LogOut, LogOut,
Mail, Mail,
Merge,
Pencil, Pencil,
Plus, Plus,
ShieldCheck, ShieldCheck,
@ -298,6 +299,8 @@ export function AccountSettings() {
<SecondaryEmailSection secondaryEmails={me.secondary_emails} onChanged={loadMe} /> <SecondaryEmailSection secondaryEmails={me.secondary_emails} onChanged={loadMe} />
<AccountMergeSection />
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} /> <PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
<NotificationEmailPrefsSection /> <NotificationEmailPrefsSection />
@ -1132,11 +1135,11 @@ function EmailSection({ email }: { email: string }) {
) )
} }
// Én person, flere e-postadresser (FEATURE_BACKLOG.md) -- kun det enkle // Én person, flere e-postadresser (FEATURE_BACKLOG.md "Del 1") -- kun det
// tilfellet: en FRI, ukrevd adresse legges til og verifiseres, og kan // enkle tilfellet: en FRI, ukrevd adresse legges til og verifiseres, og kan
// deretter brukes til innlogging i tillegg til hovedadressen. Ekte // deretter brukes til innlogging i tillegg til hovedadressen. Tilhører
// konto-sammenslåing (adressen tilhører allerede en annen konto) er // adressen ALLEREDE en annen konto, avviser backend med 409 DUPLICATE og
// bevisst IKKE støttet -- backend avviser da med en tydelig 409 DUPLICATE. // henviser til AccountMergeSection under ("Del 2", ADR-080).
function SecondaryEmailSection({ function SecondaryEmailSection({
secondaryEmails, secondaryEmails,
onChanged, 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<string, string> = { owner: "eier", admin: "administrator", member: "medlem" }
function AccountMergeSection() {
const [email, setEmail] = useState("")
const [preview, setPreview] = useState<ApiMergePreview | null>(null)
const [loadingPreview, setLoadingPreview] = useState(false)
const [requesting, setRequesting] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(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 (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Merge aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">Slå sammen med en annen konto</h2>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
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.
</p>
{sent ? (
<p className="text-sm font-medium text-primary text-pretty">
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.
</p>
) : preview ? (
<div className="flex flex-col gap-3">
<p className="text-sm font-semibold text-foreground">
{preview.loser_display_name}
{preview.loser_email ? ` (${preview.loser_email})` : ""} slås inn i denne kontoen:
</p>
<ul className="flex flex-col gap-1.5 rounded-2xl border border-border bg-background p-3 text-sm text-foreground">
{preview.organization_memberships.map((m) => (
<li key={m.organization_id}>
{m.organization_name}: du blir {ROLE_LABELS[m.resulting_role] ?? m.resulting_role}
{m.keeper_role && m.loser_role && m.keeper_role !== m.resulting_role
? ` (oppgradert fra ${ROLE_LABELS[m.keeper_role] ?? m.keeper_role})`
: ""}
</li>
))}
{preview.player_links.map((p) => (
<li key={p.organization_id}>
{p.organization_name}: spiller-koblingen slås sammen
{p.colliding_registrations > 0
? ` (${p.colliding_registrations} dobbeltregistrering${p.colliding_registrations === 1 ? "" : "er"} ryddes opp)`
: ""}
</li>
))}
{preview.round_count > 0 && <li>{preview.round_count} runde{preview.round_count === 1 ? "" : "r"} flyttes over</li>}
{preview.friend_count > 0 && (
<li>
{preview.friend_count} venn{preview.friend_count === 1 ? "" : "er"} flyttes over
{preview.shared_friend_count > 0 ? ` (${preview.shared_friend_count} felles, slås sammen)` : ""}
</li>
)}
{preview.were_friends_with_each_other && <li>Dere var venner med hverandre -- fjernes ved sammenslåing</li>}
{preview.secondary_email_count > 0 && (
<li>
{preview.secondary_email_count} ekstra e-postadresse{preview.secondary_email_count === 1 ? "" : "r"} flyttes over
</li>
)}
</ul>
<div className="flex flex-col gap-2 rounded-2xl border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-semibold text-foreground text-pretty">
Dette kan ikke angres. Den andre kontoen slettes helt snart
sammenslåingen er bekreftet.
</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => setPreview(null)}
className="min-h-11 flex-1 justify-center rounded-xl font-bold"
>
Avbryt
</Button>
<Button
type="button"
variant="destructive"
disabled={requesting}
onClick={handleRequest}
className="min-h-11 flex-1 justify-center gap-1.5 rounded-xl font-bold"
>
<Merge aria-hidden="true" className="size-4" />
{requesting ? "Sender …" : "Send bekreftelse"}
</Button>
</div>
</div>
</div>
) : (
<form onSubmit={handlePreview} className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="merge-email" className="text-sm font-semibold">
E-post til den andre kontoen
</Label>
<Input
id="merge-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-12 rounded-xl"
/>
</div>
<Button
type="submit"
disabled={loadingPreview || email.trim() === ""}
className="h-12 shrink-0 rounded-xl font-semibold"
>
{loadingPreview ? "Ser etter …" : "Se hva som slås sammen"}
</Button>
</form>
)}
{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)

272
tests/test_account_merge.py Normal file
View file

@ -0,0 +1,272 @@
"""
Kontosammenslåing (ADR-080) -- dekker konfliktkartet fra planen: org-rolle-
kollisjon, player-sammenslåing med ekte team_roster-kollisjon, venn-
selvlenke, delt-venn-dedup, sekundær-e-post-overføring, token-utløp, og en
full ende-til-ende-sammenslåing som beviser taper-kontoen faktisk er borte.
"""
import pytest
from fastapi import HTTPException
from app.account_merge import compute_merge_preview, execute_merge
from app.auth import resolve_user_id_by_email
from tests.conftest import (
add_membership,
create_friendship,
create_org,
create_player,
create_team,
create_team_roster,
create_tournament,
create_user,
)
import app.db as app_db
async def test_resolve_user_id_by_email_checks_primary_then_secondary(pool):
user_id = await create_user(email="primary@pytest.invalid")
async with app_db.plain_connection() as conn:
resolved = await resolve_user_id_by_email(conn, "primary@pytest.invalid")
assert resolved == user_id
await conn.execute(
"INSERT INTO user_secondary_email (user_id, email) VALUES ($1, $2)", user_id, "secondary@pytest.invalid"
)
resolved = await resolve_user_id_by_email(conn, "secondary@pytest.invalid")
assert resolved == user_id
assert await resolve_user_id_by_email(conn, "nobody@pytest.invalid") is None
async def test_preview_shows_org_membership_conflict_and_resulting_role(pool):
org_id = await create_org()
keeper_id = await create_user()
loser_id = await create_user()
await add_membership(org_id, keeper_id, role="member")
await add_membership(org_id, loser_id, role="owner")
preview = await compute_merge_preview(keeper_id, loser_id)
assert len(preview.organization_memberships) == 1
m = preview.organization_memberships[0]
assert m.keeper_role == "member"
assert m.loser_role == "owner"
assert m.resulting_role == "owner"
async def test_merge_resolves_org_membership_to_highest_role(pool):
org_id = await create_org()
keeper_id = await create_user()
loser_id = await create_user()
await add_membership(org_id, keeper_id, role="member")
await add_membership(org_id, loser_id, role="owner")
await execute_merge(keeper_id, loser_id)
async with app_db.plain_connection() as conn:
rows = await conn.fetch("SELECT user_id::text AS user_id, role FROM organization_membership WHERE organization_id = $1", org_id)
assert len(rows) == 1
assert rows[0]["user_id"] == keeper_id
assert rows[0]["role"] == "owner"
async def test_merge_repoints_membership_when_only_loser_is_member(pool):
org_id = await create_org()
keeper_id = await create_user()
loser_id = await create_user()
await add_membership(org_id, loser_id, role="admin")
await execute_merge(keeper_id, loser_id)
async with app_db.plain_connection() as conn:
rows = await conn.fetch("SELECT user_id::text AS user_id, role FROM organization_membership WHERE organization_id = $1", org_id)
assert len(rows) == 1
assert rows[0]["user_id"] == keeper_id
assert rows[0]["role"] == "admin"
async def test_merge_merges_player_rows_and_dedupes_team_roster_collision(pool):
"""Begge kontoer har hver sin player-rad i SAMME org, begge rostret på
SAMME lag -- ekte UNIQUE(team_id, player_id)-kollisjon. Etter
sammenslåing skal det finnes NØYAKTIG ÉN player-rad og ÉN team_roster-
rad, eid av keeper."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
keeper_id = await create_user()
loser_id = await create_user()
keeper_player_id = await create_player(org_id, user_id=keeper_id, display_name="Keeper")
loser_player_id = await create_player(org_id, user_id=loser_id, display_name="Loser")
tournament_id = await create_tournament(org_id)
team_id = await create_team(org_id, tournament_id)
await create_team_roster(org_id, team_id, keeper_player_id)
await create_team_roster(org_id, team_id, loser_player_id)
preview = await compute_merge_preview(keeper_id, loser_id)
org_preview = next(p for p in preview.player_links if p.organization_id == org_id)
assert org_preview.keeper_has_player is True
assert org_preview.loser_has_player is True
assert org_preview.colliding_registrations == 1
await execute_merge(keeper_id, loser_id)
async with app_db.org_connection(org_id) as conn:
player_rows = await conn.fetch("SELECT id::text AS id, user_id::text AS user_id FROM player WHERE organization_id = $1", org_id)
roster_rows = await conn.fetch("SELECT player_id::text AS player_id FROM team_roster WHERE team_id = $1", team_id)
assert len(player_rows) == 1
assert player_rows[0]["user_id"] == keeper_id
assert len(roster_rows) == 1
assert roster_rows[0]["player_id"] == player_rows[0]["id"]
async def test_merge_repoints_player_when_only_loser_has_one(pool):
org_id = await create_org()
keeper_id = await create_user()
loser_id = await create_user()
await create_player(org_id, user_id=loser_id, display_name="Loser")
await execute_merge(keeper_id, loser_id)
async with app_db.org_connection(org_id) as conn:
row = await conn.fetchrow("SELECT user_id::text AS user_id FROM player WHERE organization_id = $1", org_id)
assert row["user_id"] == keeper_id
async def test_merge_deletes_self_loop_friendship(pool):
"""Keeper og taper var venner med HVERANDRE -- må slettes, ikke
re-pekes (ville brutt CHECK(requester <> addressee))."""
keeper_id = await create_user()
loser_id = await create_user()
await create_friendship(keeper_id, loser_id, status="accepted")
await execute_merge(keeper_id, loser_id)
async with app_db.plain_connection() as conn:
count = await conn.fetchval(
"SELECT count(*) FROM friendship WHERE requester_user_id = $1 OR addressee_user_id = $1", keeper_id
)
assert count == 0
async def test_merge_dedupes_shared_friend_prefers_accepted(pool):
"""Keeper og taper er BEGGE venn med samme tredjeperson -- keeperens er
'pending', taperens er 'accepted'. Etter sammenslåing skal keeper ha
NØYAKTIG ÉN vennskapsrad med tredjepersonen, status 'accepted'."""
keeper_id = await create_user()
loser_id = await create_user()
third_id = await create_user()
await create_friendship(keeper_id, third_id, status="pending")
await create_friendship(loser_id, third_id, status="accepted")
await execute_merge(keeper_id, loser_id)
async with app_db.plain_connection() as conn:
rows = await conn.fetch(
"SELECT 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,
third_id,
)
assert len(rows) == 1
assert rows[0]["status"] == "accepted"
async def test_merge_transfers_secondary_email_and_demotes_loser_primary(pool):
keeper_id = await create_user(email="keeper@pytest.invalid")
loser_id = await create_user(email="loser@pytest.invalid")
await execute_merge(keeper_id, loser_id)
async with app_db.plain_connection() as conn:
row = await conn.fetchrow(
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = 'loser@pytest.invalid'"
)
assert row is not None
assert row["user_id"] == keeper_id
async def test_full_end_to_end_merge_deletes_loser_account_and_frees_email(pool):
"""Keeper medlem i org A (member), taper medlem i BÅDE org A (owner --
kollisjon, owner skal vinne) og org B (admin -- ren repeking). Pluss
venner. Bekreft alt riktig, OG at taper-kontoen faktisk er borte
(samme bevis-mønster som ADR-032 sin egen e-postbytte-test: ny
innlogging med taperens gamle e-post oppretter en FRISK konto)."""
org_a = await create_org()
org_b = await create_org()
keeper_id = await create_user(email="keeper2@pytest.invalid")
loser_id = await create_user(email="loser2@pytest.invalid")
friend_id = await create_user()
await add_membership(org_a, keeper_id, role="member")
await add_membership(org_a, loser_id, role="owner")
await add_membership(org_b, loser_id, role="admin")
await create_friendship(keeper_id, friend_id, status="accepted")
result = await execute_merge(keeper_id, loser_id)
assert result.keeper_id == keeper_id
assert result.merged_organization_count == 2
async with app_db.plain_connection() as conn:
assert await conn.fetchval("SELECT role FROM organization_membership WHERE organization_id = $1 AND user_id = $2", org_a, keeper_id) == "owner"
assert await conn.fetchval("SELECT role FROM organization_membership WHERE organization_id = $1 AND user_id = $2", org_b, keeper_id) == "admin"
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM app_user WHERE id = $1)", loser_id) is False
# Taperens gamle e-post er nå FRI (kun keeperens sekundær-e-post,
# ikke lenger noen konto sin PRIMÆRE) -- en ny innlogging med den
# skal derfor opprette en FRISK, tom konto, ikke gjenopplive taperen.
freed_email_owner = await conn.fetchval("SELECT id FROM app_user WHERE email = 'loser2@pytest.invalid'")
assert freed_email_owner is None
secondary_owner = await conn.fetchval(
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = 'loser2@pytest.invalid'"
)
assert secondary_owner == keeper_id
async def test_confirm_rejects_expired_token(pool):
from app.routers.account_merge import AccountMergeConfirm, confirm_account_merge
from app.routers.auth import _hash_secret
keeper_id = await create_user()
loser_id = await create_user()
raw_token = "expired-test-token"
async with app_db.plain_connection() as conn:
await conn.execute(
"INSERT INTO account_merge_token (initiator_user_id, target_user_id, token_hash, expires_at) "
"VALUES ($1, $2, $3, now() - interval '1 hour')",
keeper_id,
loser_id,
_hash_secret(raw_token),
)
with pytest.raises(HTTPException) as exc_info:
await confirm_account_merge(AccountMergeConfirm(token=raw_token))
assert exc_info.value.status_code == 401
# Kontoene består uendret -- avvist token skal aldri utløse sammenslåing.
async with app_db.plain_connection() as conn:
assert await conn.fetchval("SELECT EXISTS(SELECT 1 FROM app_user WHERE id = $1)", loser_id) is True
async def test_confirm_rejects_already_consumed_token(pool):
from app.routers.account_merge import AccountMergeConfirm, confirm_account_merge
from app.routers.auth import _hash_secret
keeper_id = await create_user()
loser_id = await create_user()
raw_token = "already-used-test-token"
async with app_db.plain_connection() as conn:
await conn.execute(
"INSERT INTO account_merge_token (initiator_user_id, target_user_id, token_hash, expires_at, consumed_at) "
"VALUES ($1, $2, $3, now() + interval '1 hour', now())",
keeper_id,
loser_id,
_hash_secret(raw_token),
)
with pytest.raises(HTTPException) as exc_info:
await confirm_account_merge(AccountMergeConfirm(token=raw_token))
assert exc_info.value.status_code == 401