Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan, deploye og oppdatere .md-filer for deltaker-tilgang-runden
Bygge og scratch-verifisere sekundær e-postadresse (migrasjon 017)
Bygge HCP-historikk over tid
Sekundær e-postadresse (det enkle tilfellet) er bygget og scratch-verifisert (20/20 sjekker), test_isolation.sql fortsatt 12/12.
Hva er bygget:
Migrasjon 017_secondary_email.sql — to nye tabeller (secondary_email_token, user_secondary_email), samme bevis-eierskap-token-mønster som ADR-032.
Backend: POST /auth/secondary-email (send bekreftelseslenke), POST /auth/secondary-email/confirm (bekreft, ingen sesjon påkrevd), DELETE /auth/secondary-email/{id}. Viktigst: verify_magic_link og login_with_password løser nå en sekundær adresse til den eksisterende eierens konto i stedet for å opprette en ny, separat konto — dette er selve poenget med funksjonen.
Frontend: ny seksjon "Andre e-postadresser" i /account (ikke dashbordet — se merknad under), /verify-email håndterer nå både e-postbytte og ny-sekundær-adresse via en ?kind=secondary-parameter.
Én bevisst plasseringsavgjørelse jeg tok uten å spørre: du sa opprinnelig at hele multi-e-post-flyten burde skje "fra dashboard-siden". Jeg la likevel dette i /account, fordi jeg her kun bygger det enkle tilfellet (fri adresse) — konsistent med hvor e-postbytte (ADR-032) allerede ligger. Når/hvis den harde saken (ekte konto-sammenslåing, der data faktisk "dukker opp") bygges senere, er dashbordet trolig riktigere siden gevinsten vises der. Si fra hvis du vil at den skal flyttes allerede nå.
Verifisert grundig: ny sekundær-adresse legges IKKE til før bekreftet; token kan ikke gjenbrukes; adresse som allerede er en annens hovedadresse ELLER en annens sekundæradresse avvises tydelig; innlogging (magic-link OG passord) via sekundæradressen løses korrekt til samme, eksisterende konto; en fremmed kan ikke slette andres sekundæradresse; og — kritisk — etter sletting oppretter en ny innlogging på den adressen en helt ny, separat konto (beviser fjerningen er reell).
Ingen migrasjon kjørt mot ekte teecup_db ennå.
224 lines
8.8 KiB
Python
224 lines
8.8 KiB
Python
"""
|
|
E-postutsending for TeeCup. I dag kun magic-link-innlogging.
|
|
|
|
Egne, uavhengige SMTP-credentials fra teeoff (ADR-009) -- se .env
|
|
(TEECUP_SMTP_SERVER/PORT/USER/PASS, TEECUP_FROM_EMAIL). `smtplib` er
|
|
synkron, så selve sendingen kjøres i en tråd (asyncio.to_thread) for ikke å
|
|
blokkere event loop-en -- samme mønster som teeoffs egen, fungerende
|
|
magic-link-utsending.
|
|
|
|
Håndterer BEGGE vanlige SMTP-tilkoblingsmåter siden porten ikke er antatt på
|
|
forhånd: implisitt TLS (SMTP_SSL, typisk port 465) eller STARTTLS på en
|
|
vanlig tilkobling (typisk port 587/25).
|
|
"""
|
|
|
|
import smtplib
|
|
from asyncio import to_thread
|
|
from email.message import EmailMessage
|
|
|
|
from .config import settings
|
|
|
|
|
|
def _send_sync(to_email: str, subject: str, body: str) -> None:
|
|
msg = EmailMessage()
|
|
msg["Subject"] = subject
|
|
msg["From"] = settings.FROM_EMAIL
|
|
msg["To"] = to_email
|
|
msg.set_content(body)
|
|
|
|
if settings.SMTP_PORT == 465:
|
|
with smtplib.SMTP_SSL(settings.SMTP_SERVER, settings.SMTP_PORT) as smtp:
|
|
smtp.login(settings.SMTP_USER, settings.SMTP_PASS)
|
|
smtp.send_message(msg)
|
|
else:
|
|
with smtplib.SMTP(settings.SMTP_SERVER, settings.SMTP_PORT) as smtp:
|
|
smtp.starttls()
|
|
smtp.login(settings.SMTP_USER, settings.SMTP_PASS)
|
|
smtp.send_message(msg)
|
|
|
|
|
|
_TEMPLATES = {
|
|
"nb": {
|
|
"subject": "Logg inn på TeeCup",
|
|
"body": (
|
|
"Hei,\n\n"
|
|
"Åpne denne lenken for å logge inn på TeeCup innen {minutes} minutter:\n\n"
|
|
"{link}\n\n"
|
|
"Fungerer ikke lenken? Lim inn denne koden manuelt i stedet:\n"
|
|
"{token}\n\n"
|
|
"Ba du ikke om dette, kan du se bort fra e-posten.\n"
|
|
),
|
|
},
|
|
"en": {
|
|
"subject": "Log in to TeeCup",
|
|
"body": (
|
|
"Hi,\n\n"
|
|
"Open this link to log in to TeeCup within {minutes} minutes:\n\n"
|
|
"{link}\n\n"
|
|
"Link not working? Paste this code in manually instead:\n"
|
|
"{token}\n\n"
|
|
"If you didn't request this, you can ignore this email.\n"
|
|
),
|
|
},
|
|
}
|
|
|
|
|
|
async def send_magic_link_email(to_email: str, raw_token: str, locale: str = "nb") -> None:
|
|
"""Sender en klikkbar innloggingslenke (pluss selve koden som fallback).
|
|
|
|
`locale` valideres allerede av Pydantic (Literal["nb","en"]) på vei inn i
|
|
routeren, men faller trygt tilbake til "nb" her også hvis den skulle
|
|
komme fra et annet kall uten den garantien.
|
|
"""
|
|
template = _TEMPLATES.get(locale, _TEMPLATES["nb"])
|
|
subject = template["subject"]
|
|
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
|
|
body = template["body"].format(
|
|
minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, token=raw_token, link=link
|
|
)
|
|
await to_thread(_send_sync, to_email, subject, body)
|
|
|
|
|
|
_TWO_FACTOR_TEMPLATES = {
|
|
"nb": {
|
|
"subject": "Din engangskode for TeeCup",
|
|
"body": (
|
|
"Hei,\n\n"
|
|
"Engangskoden din er: {code}\n\n"
|
|
"Den er gyldig i 5 minutter. Ba du ikke om dette, kan du se bort fra e-posten.\n"
|
|
),
|
|
},
|
|
"en": {
|
|
"subject": "Your TeeCup one-time code",
|
|
"body": (
|
|
"Hi,\n\n"
|
|
"Your one-time code is: {code}\n\n"
|
|
"It is valid for 5 minutes. If you didn't request this, you can ignore this email.\n"
|
|
),
|
|
},
|
|
}
|
|
|
|
|
|
async def send_two_factor_code_email(to_email: str, code: str, locale: str = "nb") -> None:
|
|
"""ADR-021 Beslutning C: e-post-2FA-engangskode. Samme leveringsmønster
|
|
som magic-link (smtplib i egen tråd)."""
|
|
template = _TWO_FACTOR_TEMPLATES.get(locale, _TWO_FACTOR_TEMPLATES["nb"])
|
|
body = template["body"].format(code=code)
|
|
await to_thread(_send_sync, to_email, template["subject"], body)
|
|
|
|
|
|
_INVITATION_TEMPLATES = {
|
|
"nb": {
|
|
"subject": "Du er invitert til {org} på TeeCup",
|
|
"body": (
|
|
"Hei,\n\n"
|
|
"{inviter} har invitert deg til å bli {role} i {org} på TeeCup.\n\n"
|
|
"Logg inn med denne e-postadressen på {link} for å godta invitasjonen "
|
|
"-- den kobles automatisk til kontoen din ved innlogging.\n"
|
|
),
|
|
},
|
|
"en": {
|
|
"subject": "You're invited to {org} on TeeCup",
|
|
"body": (
|
|
"Hi,\n\n"
|
|
"{inviter} has invited you to become {role} of {org} on TeeCup.\n\n"
|
|
"Log in with this email address at {link} to accept the invitation "
|
|
"-- it's linked to your account automatically on login.\n"
|
|
),
|
|
},
|
|
}
|
|
|
|
_ROLE_LABELS_NB = {"owner": "eier", "admin": "administrator", "member": "medlem"}
|
|
_ROLE_LABELS_EN = {"owner": "an owner", "admin": "an administrator", "member": "a member"}
|
|
|
|
|
|
async def send_organization_invitation_email(
|
|
to_email: str, org_name: str, role: str, inviter_name: str, locale: str = "nb"
|
|
) -> None:
|
|
"""ADR-022 Beslutning B: ingen klikkbar aksept-lenke -- invitasjonen
|
|
godtas automatisk ved neste innlogging med matchende e-post (samme
|
|
mønster som spiller-e-post-kobling, ADR-017)."""
|
|
template = _INVITATION_TEMPLATES.get(locale, _INVITATION_TEMPLATES["nb"])
|
|
role_label = (_ROLE_LABELS_NB if locale == "nb" else _ROLE_LABELS_EN).get(role, role)
|
|
subject = template["subject"].format(org=org_name)
|
|
body = template["body"].format(
|
|
inviter=inviter_name, role=role_label, org=org_name, link=settings.PUBLIC_BASE_URL
|
|
)
|
|
await to_thread(_send_sync, to_email, subject, body)
|
|
|
|
|
|
_EMAIL_CHANGE_TEMPLATES = {
|
|
"nb": {
|
|
"subject": "Bekreft ny e-postadresse for TeeCup",
|
|
"body": (
|
|
"Hei,\n\n"
|
|
"Noen ba om å endre TeeCup-kontoens e-postadresse til denne adressen. "
|
|
"Åpne lenken under innen {minutes} minutter for å bekrefte:\n\n"
|
|
"{link}\n\n"
|
|
"Ba du ikke om dette selv, kan du se bort fra e-posten -- ingenting "
|
|
"endres uten at lenken åpnes.\n"
|
|
),
|
|
},
|
|
"en": {
|
|
"subject": "Confirm your new TeeCup email address",
|
|
"body": (
|
|
"Hi,\n\n"
|
|
"Someone requested changing this TeeCup account's email address to this "
|
|
"address. Open the link below within {minutes} minutes to confirm:\n\n"
|
|
"{link}\n\n"
|
|
"If you didn't request this yourself, you can ignore this email -- "
|
|
"nothing changes unless the link is opened.\n"
|
|
),
|
|
},
|
|
}
|
|
|
|
|
|
async def send_email_change_confirmation(to_email: str, raw_token: str, locale: str = "nb") -> None:
|
|
"""ADR-031-oppfølging: e-post er innloggings-identifikatoren, kan derfor
|
|
ikke endres med en enkel PATCH -- den NYE adressen må bevise eierskap
|
|
FØR selve kontoen endres. Samme token-i-lenke-mønster som magic-link."""
|
|
template = _EMAIL_CHANGE_TEMPLATES.get(locale, _EMAIL_CHANGE_TEMPLATES["nb"])
|
|
link = f"{settings.PUBLIC_BASE_URL}/verify-email?token={raw_token}"
|
|
body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, link=link)
|
|
await to_thread(_send_sync, to_email, template["subject"], body)
|
|
|
|
|
|
_SECONDARY_EMAIL_TEMPLATES = {
|
|
"nb": {
|
|
"subject": "Bekreft ekstra e-postadresse for TeeCup",
|
|
"body": (
|
|
"Hei,\n\n"
|
|
"Noen la til denne adressen som en EKSTRA (sekundær) e-postadresse på "
|
|
"en TeeCup-konto -- kontoens hovedadresse endres IKKE. Åpne lenken "
|
|
"under innen {minutes} minutter for å bekrefte at du eier denne "
|
|
"adressen:\n\n"
|
|
"{link}\n\n"
|
|
"Ba du ikke om dette selv, kan du se bort fra e-posten -- ingenting "
|
|
"legges til uten at lenken åpnes.\n"
|
|
),
|
|
},
|
|
"en": {
|
|
"subject": "Confirm an additional TeeCup email address",
|
|
"body": (
|
|
"Hi,\n\n"
|
|
"Someone added this address as an ADDITIONAL (secondary) email on a "
|
|
"TeeCup account -- the account's primary address is NOT changed. Open "
|
|
"the link below within {minutes} minutes to confirm you own this "
|
|
"address:\n\n"
|
|
"{link}\n\n"
|
|
"If you didn't request this yourself, you can ignore this email -- "
|
|
"nothing is added unless the link is opened.\n"
|
|
),
|
|
},
|
|
}
|
|
|
|
|
|
async def send_secondary_email_verification(to_email: str, raw_token: str, locale: str = "nb") -> None:
|
|
"""Én person, flere e-postadresser (notert i FEATURE_BACKLOG.md) --
|
|
kun det enkle tilfellet (fri, ukrevd adresse). Samme
|
|
token-i-lenke-mønster som send_email_change_confirmation, egen mal
|
|
siden budskapet er reelt forskjellig ("legges til" vs. "endres til")."""
|
|
template = _SECONDARY_EMAIL_TEMPLATES.get(locale, _SECONDARY_EMAIL_TEMPLATES["nb"])
|
|
link = f"{settings.PUBLIC_BASE_URL}/verify-email?token={raw_token}&kind=secondary"
|
|
body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, link=link)
|
|
await to_thread(_send_sync, to_email, template["subject"], body)
|