teecup/app/routers/account_merge.py
Erol Haagenrud 7d28727cb7 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>
2026-08-17 07:56:41 +02:00

135 lines
5.8 KiB
Python

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