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:
parent
cdd3719ab4
commit
7d28727cb7
13 changed files with 1792 additions and 30 deletions
42
077_account_merge.sql
Normal file
42
077_account_merge.sql
Normal 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;
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
57
CHANGELOG.md
57
CHANGELOG.md
|
|
@ -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
567
app/account_merge.py
Normal 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 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,
|
||||||
|
)
|
||||||
17
app/auth.py
17
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:
|
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
|
||||||
|
|
|
||||||
58
app/email.py
58
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)
|
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",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
135
app/routers/account_merge.py
Normal file
135
app/routers/account_merge.py
Normal 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 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"])
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
21
frontend/app/kontosammenslaing/[token]/page.tsx
Normal file
21
frontend/app/kontosammenslaing/[token]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
225
frontend/components/account-merge-confirm-form.tsx
Normal file
225
frontend/components/account-merge-confirm-form.tsx
Normal 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 nå 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 så 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 så 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
272
tests/test_account_merge.py
Normal 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
|
||||||
Loading…
Reference in a new issue