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