Update Todos
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å.
This commit is contained in:
parent
df08339654
commit
b376f095be
8 changed files with 580 additions and 40 deletions
|
|
@ -340,7 +340,8 @@
|
|||
"Bash(python3 -m py_compile app/routers/auth.py)",
|
||||
"Bash(python3 -m py_compile app/routers/auth.py app/routers/scoring.py app/routers/matches.py app/routers/tournaments.py app/routers/courses.py app/routers/messaging.py app/routers/registration.py app/team_authz.py)",
|
||||
"Bash(rm -f /opt/requirements.txt)",
|
||||
"Bash(python3 -m py_compile app/routers/*.py app/*.py)"
|
||||
"Bash(python3 -m py_compile app/routers/*.py app/*.py)",
|
||||
"Bash(python3 -m py_compile app/routers/auth.py app/email.py)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/opt/teeoff/deploy",
|
||||
|
|
|
|||
49
017_secondary_email.sql
Normal file
49
017_secondary_email.sql
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
-- =====================================================================
|
||||
-- TeeCup — sekundær, verifisert e-postadresse per konto (migrasjon 017)
|
||||
-- =====================================================================
|
||||
-- Oppfølging av ADR-032 (verifisert e-postbytte), reist av brukeren rett
|
||||
-- etterpå: én person kan ha flere e-postadresser i omløp samtidig (privat
|
||||
-- vs. jobb-adresse en organisator har lagt inn en spiller/invitasjon på).
|
||||
-- Dette dekker BEVISST kun det enkle tilfellet -- en FRI, ukrevd adresse
|
||||
-- legges til og verifiseres. Ekte konto-SAMMENSLÅING (adressen tilhører
|
||||
-- allerede en annen, eksisterende konto) er en egen, større, separat sak
|
||||
-- (se FEATURE_BACKLOG.md "Én person, flere e-postadresser") og håndheves
|
||||
-- IKKE her -- unikheten under garanterer at en adresse aldri kan stå som
|
||||
-- sekundær på to kontoer samtidig (samme rad ville krevd en eksplisitt
|
||||
-- sammenslåingsbeslutning som ikke finnes ennå).
|
||||
--
|
||||
-- To tabeller, samme token-hash-og-utløp-mønster som email_change_token
|
||||
-- (016): en midlertidig verifiseringstoken (bevis eierskap FØR raden
|
||||
-- faktisk opprettes -- unngår en "krevd men aldri bekreftet"-adresse som
|
||||
-- likevel opptar en unik plass), og selve den verifiserte adressen.
|
||||
-- =====================================================================
|
||||
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
CREATE TABLE secondary_email_token (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
|
||||
email citext NOT NULL,
|
||||
token_hash text NOT NULL UNIQUE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
consumed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX ON secondary_email_token (user_id);
|
||||
|
||||
CREATE TABLE user_secondary_email (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
|
||||
-- Globalt unik -- kan aldri stå som sekundær på to kontoer samtidig,
|
||||
-- og aldri samtidig være en ANNEN kontos primære e-post (håndhevet i
|
||||
-- app-laget ved innsetting, se app/routers/auth.py -- en ren DB-CHECK
|
||||
-- på tvers av to tabeller er ikke mulig i Postgres).
|
||||
email citext NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX ON user_secondary_email (user_id);
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON secondary_email_token TO teecup_app;
|
||||
GRANT SELECT, INSERT, DELETE ON user_secondary_email TO teecup_app;
|
||||
84
CLAUDE.md
84
CLAUDE.md
|
|
@ -2105,31 +2105,87 @@ Ferdig og verifisert:
|
|||
avhengighets-bivirkning, ingen backend-kode rørt). Begge containere
|
||||
boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no` upåvirket.
|
||||
|
||||
- **Dashboard/konto-runde (2026-07-21): to store punkter reist samtidig av
|
||||
brukeren.** (1) "Alt relatert til dashboard/account/brukerkontoer" —
|
||||
scopet sammen med brukeren via spørsmål: tom-tilstand-redesignet ble
|
||||
PAUSERT (bruker ba om "juster retningen" og reiste et dypere spørsmål om
|
||||
hvorvidt organisasjon fortsatt bør være "det som meldes først" — se eget
|
||||
punkt i FEATURE_BACKLOG.md, min vurdering: nei, bør bli ett likestilt
|
||||
valg blant flere). (2) Et helt nytt, stort forslag om frittstående
|
||||
rundeføring + detaljert statistikk (putter/chip/bunkerslag/straffeslag/
|
||||
førsteputt-lengde) UTEN turnering/organisasjon — grundig notert i
|
||||
FEATURE_BACKLOG.md med en eksplisitt arkitektur-advarsel: dette
|
||||
UTFORDRER tenant-invarianten (`organization_id` på alle domenetabeller)
|
||||
direkte og trenger en egen ADR, ikke bygget denne runden.
|
||||
**Deltaker-tilgang til lag-chat/scorekort — ✅ BYGGET OG LIVE
|
||||
2026-07-21** (én av tre konkrete følgepunkter brukeren bekreftet i samme
|
||||
runde, de to andre — sekundær e-post, HCP-historikk — tas fortløpende
|
||||
etterpå): fjernet den blanke `get_authorized_org`-sperren fra ni
|
||||
endepunkter på tvers av `messaging.py`/`scoring.py`/`matches.py`/
|
||||
`tournaments.py`/`courses.py`, erstattet med de ALLEREDE eksisterende
|
||||
domene-sjekkene (`user_is_rostered_on_team`/`user_is_match_participant`/
|
||||
`user_is_team_captain`) som viste seg å støtte ikke-org-medlemmer helt
|
||||
fint fra før — de var bare aldri nåbare. To nye delte hjelpefunksjoner i
|
||||
`team_authz.py` (`is_org_member`, `user_is_tournament_participant` —
|
||||
sistnevnte FLYTTET dit fra `registration.py` for å unngå sirkulær
|
||||
import) dekker de endepunktene som IKKE hadde noen finkornet sjekk fra
|
||||
før (ren fjerning der ville åpnet dem for enhver innlogget bruker).
|
||||
`/auth/me` sin `my_tournaments` fikk `my_session_id`/`my_match_id`;
|
||||
"Mine runder"-kortet fikk "Lag-chat"/"Scorekort"-lenker.
|
||||
**Scratch-verifisert grundig, 43 sjekker** (isolert scratch-rolle+MinIO+
|
||||
engangs API-container): rostret ikke-medlem fikk korrekt tilgang
|
||||
overalt (inkl. faktisk sendt chat-melding og hull-resultat), fortsatt
|
||||
avvist fra det ANDRE lagets chat, en helt fremmed bruker avvist overalt,
|
||||
org-eier beholder alt UNNTATT lag-chat (uendret, med vilje), kryss-org-
|
||||
isolasjon bekreftet. **Reelt funn UNDER selve scratch-testingen:** en
|
||||
rostret-men-ikke-kaptein spiller ble først uventet GODTATT til walkover
|
||||
— viste seg å være en allerede tiltenkt, dokumentert fallback
|
||||
(`user_is_team_captain`: "ingen kaptein utpekt ennå = enhver rostret
|
||||
spiller godtas"), ikke en bug — testen ble rettet (la til en faktisk
|
||||
kaptein) og bekreftet deretter riktig avvisning. `test_isolation.sql`
|
||||
12/12 uendret (ingen skjemaendring). Ekte typesjekket produksjonsbuild
|
||||
kjørt og bekreftet.
|
||||
**Rullet ut live 2026-07-21**, bruker bekreftet eksplisitt: ingen
|
||||
migrasjon, `docker compose up -d --build teecup_api teecup_frontend`,
|
||||
begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
|
||||
upåvirket.
|
||||
|
||||
Neste steg:
|
||||
1. **Venter på brukerens retningsbekreftelse:** redesign av dashbordets
|
||||
tom-tilstand ved første innlogging (mindre organisator-vridd, se
|
||||
FEATURE_BACKLOG.md) — forslag lagt frem 2026-07-20, ikke bygget.
|
||||
2. **Notert, IKKE designet i detalj:** én person med flere e-post-adresser
|
||||
1. **Pågår, samme økt:** bygge de to gjenværende bekreftede punktene fra
|
||||
dashboard/konto-runden (2026-07-21) — sekundær e-postadresse (enkelt
|
||||
tilfelle, gjenbruker ADR-032s token-mønster) og HCP-historikk over tid.
|
||||
2. **Pauset, venter på retning:** dashbordets tom-tilstand ved første
|
||||
innlogging. Brukeren ba om å justere det opprinnelige 2026-07-20-
|
||||
forslaget i lys av et dypere spørsmål — bør organisasjon fortsatt være
|
||||
"det som meldes først"? Min vurdering (se FEATURE_BACKLOG.md): nei,
|
||||
bør bli ett likestilt valg blant flere. Bygging avhenger delvis av
|
||||
punktet under (hvilken tredje kortform tom-skjermen skal ha).
|
||||
3. **Nytt, stort, IKKE designet:** frittstående rundeføring + detaljert
|
||||
statistikk (putter/chip/bunkerslag/straffeslag/førsteputt-lengde) UTEN
|
||||
turnering/organisasjon, reist 2026-07-21. Utfordrer tenant-invarianten
|
||||
(`organization_id` på alle domenetabeller) direkte — trenger en egen,
|
||||
dedikert ADR-runde, IKKE noe som skal bygges i forlengelsen av en
|
||||
vanlig funksjonsrunde. Se full analyse og åpne spørsmål i
|
||||
FEATURE_BACKLOG.md.
|
||||
4. **Notert, IKKE designet i detalj:** én person med flere e-post-adresser
|
||||
— legge til en verifisert sekundær-adresse fra dashbordet, med
|
||||
turneringer/data som "dukker opp"/slås sammen deretter, og fremtidige
|
||||
innlogginger med enten adresse. Fanget grundig i FEATURE_BACKLOG.md,
|
||||
inkl. et reelt uløst spørsmål (ekte konto-sammenslåing hvis adressen
|
||||
allerede tilhører en annen eksisterende konto) som trenger egen,
|
||||
separat designrunde.
|
||||
3. Personlig landingsside + profil (ADR-031) OG e-post/mobil (ADR-032) er
|
||||
BEGGE LIVE. Naturlig oppfølging: deltaker-tilgang (uten org-medlemskap)
|
||||
til lag-chat/scorekort — bevisst utenfor omfang, se FEATURE_BACKLOG.md.
|
||||
4. **Punkt 2 fra 2026-07-20-runden (midlertidige spillere):** forslag klart
|
||||
i FEATURE_BACKLOG.md, tre åpne spørsmål, ingen kode skrevet.
|
||||
5. Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
|
||||
separat designrunde. **Merk:** dette overlapper med punkt 1 sin
|
||||
"sekundær e-postadresse" — punkt 1 er det ENKLE, avgrensede tilfellet
|
||||
(fri adresse), dette punktet er det harde tilfellet (adressen tilhører
|
||||
allerede en annen konto).
|
||||
5. **Deltaker-tilgang til lag-chat/scorekort er nå BYGGET OG LIVE**
|
||||
(2026-07-21, se status over) — ADR-031s tidligere notert begrensning
|
||||
er dermed tettet.
|
||||
6. Oppfølgingspunkt fra PWA-runden, eksplisitt notert på brukerens
|
||||
forespørsel: offline-scoreregistrering er ALDRI browser-testet i
|
||||
praksis (kun kodegjennomgang + build-verifisering, se ADR-028). Bruker
|
||||
bør selv åpne et scorekort, skru på Chrome DevTools sin Offline-bryter,
|
||||
registrere et par slag, skru nettet på igjen, og bekrefte at de faktisk
|
||||
synkes — først da er flyten reelt bevist.
|
||||
6. **Alle fire UI-/UX-hull notert 2026-07-19 er nå fikset** (datovisning
|
||||
ADR-030, medlemsside-ruten, «ny organisasjon», dato-sammendrag — se
|
||||
status over, 2026-07-21). Ingen gjenstår fra den runden.
|
||||
7. Fortsatt åpne beslutninger fra FEATURE_BACKLOG.md: kode-regenerering for
|
||||
ADR-020, korrigering-godkjenning fra motpart, video/1-til-1-meldinger
|
||||
(bevisst utsatt i ADR-025).
|
||||
|
|
|
|||
|
|
@ -1049,6 +1049,9 @@ gjort i denne runden. Notifikasjons-/aktivitetsfeed og HCP-historikk over
|
|||
tid også bevisst utenfor omfang v1 (samme begrunnelse som opprinnelig
|
||||
forslag).
|
||||
|
||||
**Oppdatering 2026-07-21 — deltaker-tilgang til lag-chat/scorekort er
|
||||
dermed ✅ BYGGET OG LIVE, se egen seksjon lenger ned.**
|
||||
|
||||
**Scratch-verifisert, 15 sjekker** (profil-CRUD komplett, inkl. sletting av
|
||||
enkeltfelt og avatar; en EKTE ren spiller uten org-medlemskap ser riktig
|
||||
`my_tournaments`; den kritiske sikkerhetssjekken: samme spiller får nå se
|
||||
|
|
@ -1142,6 +1145,75 @@ koblingen skal skje).
|
|||
|
||||
---
|
||||
|
||||
## Deltaker-tilgang til lag-chat og scorekort (uten org-medlemskap) — ✅ BYGGET OG LIVE 2026-07-21
|
||||
|
||||
Direkte oppfølging av ADR-031s kjente, notert begrensning: "Mine runder"
|
||||
lenket til den offentlige turnering-siden, men IKKE til lagets private
|
||||
chat eller det skrivbare scorekortet — begge krevde fortsatt ekte
|
||||
organisasjonsmedlemskap (`get_authorized_org`), noe en ren, rostret/
|
||||
påmeldt spiller (uten organisasjonsmedlemskap) ikke har.
|
||||
|
||||
**Kjernefunn ved gjennomlesing (ikke antatt):** de faktiske
|
||||
autorisasjonsprimitivene (`user_is_rostered_on_team`,
|
||||
`user_is_match_participant`, `user_is_team_captain`,
|
||||
`own_team_ids` — alle i `app/team_authz.py`/`app/blind_draw.py`) støttet
|
||||
ALLEREDE ikke-org-medlemmer korrekt overalt — de var bare plassert BAK en
|
||||
ekstra, blank `Depends(get_authorized_org)`-sperre på ni endepunkter på
|
||||
tvers av fire filer. Fikset ved kirurgisk å fjerne akkurat den sperren fra
|
||||
disse ni (lag-chat lese/skrive/slette i `messaging.py`; scorekort-lesing,
|
||||
slag-/hull-resultat-innsending, walkover i `scoring.py`; match-/lag-/
|
||||
økt-listing i `matches.py`/`tournaments.py`; walkover-på-turnering-nivå i
|
||||
`tournaments.py`; bane-hull i `courses.py`) — de eksisterende
|
||||
domene-sjekkene (som allerede har egen org-admin-fallback der det er
|
||||
tiltenkt) er den REELLE sikkerhetsgrensen, ikke `get_authorized_org`.
|
||||
|
||||
**For endepunkter som IKKE hadde noen finkornet sjekk i det hele tatt**
|
||||
(f.eks. `get_scorecard`, `list_sessions`, `list_teams` — disse stolte
|
||||
UTELUKKENDE på org-medlemskap) ville en ren fjerning av sperren latt EN
|
||||
HVILKEN SOM HELST innlogget bruker se dem — løst med et nytt, eksplisitt
|
||||
`is_org_member(...) OR user_is_tournament_participant(...)`-OR (begge nye
|
||||
hjelpefunksjoner i `team_authz.py`). `user_is_tournament_participant` er
|
||||
FLYTTET dit fra `registration.py` (het `is_participant` der) — org-scopede
|
||||
routere kan ikke importere fra `registration.py` uten sirkulær import
|
||||
(registration.py importerer FRA dem), men alle importerer allerede fritt
|
||||
fra den avhengighetsfrie `team_authz.py`. `courses.py` sin `list_holes`
|
||||
fikk en bevisst LØSERE sjekk (`user_is_org_player` — kun "koblet til NOEN
|
||||
spillerprofil i org-en", ikke bundet til én turnering) siden par/
|
||||
stroke-index er lavsensitiv banedata, ikke spillerdata.
|
||||
|
||||
**`/auth/me` utvidet** med `my_session_id`/`my_match_id` per rad i
|
||||
`my_tournaments` (en av spillerens egne matcher, ikke-avgjort foretrukket)
|
||||
— lar frontend lenke direkte til riktig chat/scorekort uten at spilleren
|
||||
selv må navigere via program-/blind draw-skjermene (som fortsatt krever
|
||||
org-medlemskap for andre formål). "Mine runder"-kortet i `dashboard.tsx`
|
||||
fikk to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når
|
||||
`my_match_id` finnes).
|
||||
|
||||
**Scratch-verifisert grundig, 43 automatiserte sjekker** (isolert
|
||||
`teecup_app_scratch`-rolle, isolert scratch-MinIO, engangs API-container):
|
||||
en rostret spiller UTEN org-medlemskap fikk korrekt tilgang til alt de ni
|
||||
endepunktene dekker (inkl. faktisk å SENDE en chat-melding og et
|
||||
hull-resultat); samme spiller fortsatt korrekt AVVIST fra det andre laget
|
||||
sin chat; en helt fremmed innlogget bruker (ingen spillerkobling i org-en
|
||||
i det hele tatt) avvist overalt; org-eier beholder full tilgang til alt
|
||||
UNNTATT lag-chat (ekte privat, med vilje uendret — ADR-025); kryss-org-
|
||||
isolasjon bekreftet (kan ikke nå egen turnering via en ANNEN org-id);
|
||||
og — et reelt funn UNDER testingen, ikke en bug — en rostret-men-ikke-
|
||||
utpekt-kaptein spiller ble først FEILAKTIG godtatt til walkover fordi
|
||||
laget ennå ikke hadde noen utpekt kaptein (allerede dokumentert,
|
||||
tiltenkt fallback i `user_is_team_captain`: "ingen kaptein ennå = enhver
|
||||
rostret spiller godtas") — testen ble korrigert (la til en faktisk
|
||||
kaptein) og bekreftet deretter riktig avvisning av ikke-kapteinen.
|
||||
`test_isolation.sql` fortsatt 12/12 (ingen skjemaendring). Ekte
|
||||
typesjekket produksjonsbuild av frontend kjørt og bekreftet.
|
||||
|
||||
**Rullet ut live 2026-07-21**, bruker bekreftet eksplisitt: ingen
|
||||
migrasjon, `docker compose up -d --build teecup_api teecup_frontend`,
|
||||
begge containere boot-et rent, `/health`/`/dashboard` → 200, `teeoff.no`
|
||||
upåvirket.
|
||||
|
||||
---
|
||||
|
||||
## Dashboard: tom-tilstand ved første innlogging — 📋 UNDER REVURDERING (2026-07-21), IKKE bygget
|
||||
|
||||
Brukeren påpekte 2026-07-20 at dagens tomme-tilstand ("Du har ingen
|
||||
|
|
|
|||
41
app/email.py
41
app/email.py
|
|
@ -181,3 +181,44 @@ async def send_email_change_confirmation(to_email: str, raw_token: str, locale:
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,12 @@ from ..auth import (
|
|||
)
|
||||
from ..config import settings
|
||||
from ..db import org_connection, plain_connection
|
||||
from ..email import send_email_change_confirmation, send_magic_link_email, send_two_factor_code_email
|
||||
from ..email import (
|
||||
send_email_change_confirmation,
|
||||
send_magic_link_email,
|
||||
send_secondary_email_verification,
|
||||
send_two_factor_code_email,
|
||||
)
|
||||
from ..errors import app_error, translate_db_errors
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
|
@ -244,23 +249,35 @@ async def verify_magic_link(body: MagicLinkVerify, response: Response, request:
|
|||
raise app_error(401, "INVALID_MAGIC_LINK", "Lenken er ugyldig, brukt eller utløpt.")
|
||||
email = token_row["email"]
|
||||
|
||||
placeholder_name = email.split("@")[0]
|
||||
user_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO app_user (email, display_name, preferred_locale)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (email) WHERE email IS NOT NULL DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
email,
|
||||
body.display_name or placeholder_name,
|
||||
token_row["locale"],
|
||||
# Sekundær e-post (FEATURE_BACKLOG.md "Én person, flere e-post-
|
||||
# adresser") løses til EIERENS eksisterende konto -- må sjekkes FØR
|
||||
# app_user-oppslaget under, ellers ville en innlogging på en
|
||||
# sekundær adresse (som med vilje ALDRI står i app_user.email)
|
||||
# stille opprettet en helt ny, separat, feilaktig konto.
|
||||
secondary_owner_id = await conn.fetchval(
|
||||
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE email = $1", email
|
||||
)
|
||||
if user_id is None:
|
||||
# En annen samtidig verifisering for samme e-post vant innsettingen
|
||||
# (eller brukeren fantes allerede) -- preferred_locale røres IKKE
|
||||
# her, kun ved førstegangsopprettelse (se modul-docstring).
|
||||
user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
|
||||
if secondary_owner_id is not None:
|
||||
user_id = secondary_owner_id
|
||||
else:
|
||||
placeholder_name = email.split("@")[0]
|
||||
user_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO app_user (email, display_name, preferred_locale)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (email) WHERE email IS NOT NULL DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
email,
|
||||
body.display_name or placeholder_name,
|
||||
token_row["locale"],
|
||||
)
|
||||
if user_id is None:
|
||||
# En annen samtidig verifisering for samme e-post vant
|
||||
# innsettingen (eller brukeren fantes allerede) --
|
||||
# preferred_locale røres IKKE her, kun ved førstegangs-
|
||||
# opprettelse (se modul-docstring).
|
||||
user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
|
||||
|
||||
# Koble enhver player-rad (ADR-017) OG enhver ventende
|
||||
# organisasjonsinvitasjon (ADR-022) med matchende e-post til denne
|
||||
|
|
@ -287,6 +304,17 @@ async def login_with_password(body: PasswordLoginRequest, response: Response, re
|
|||
user_row = await conn.fetchrow(
|
||||
"SELECT id::text AS id, password_hash FROM app_user WHERE email = $1", email
|
||||
)
|
||||
if user_row is None:
|
||||
# Kan være en sekundær adresse (FEATURE_BACKLOG.md "Én person,
|
||||
# flere e-postadresser") -- passordet ligger uansett på selve
|
||||
# kontoen, ikke adressen, så slå opp eieren og fortsett normalt.
|
||||
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:
|
||||
user_row = await conn.fetchrow(
|
||||
"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 /
|
||||
# feil passord) -- unngår enumerering, samme prinsipp som magic-link.
|
||||
if (
|
||||
|
|
@ -582,6 +610,11 @@ class MyTournament(BaseModel):
|
|||
my_match_id: str | None
|
||||
|
||||
|
||||
class SecondaryEmailOut(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
|
||||
|
||||
class Me(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
|
|
@ -603,6 +636,7 @@ class Me(BaseModel):
|
|||
mobile_number: str | None
|
||||
avatar_url: str | None
|
||||
my_tournaments: list[MyTournament]
|
||||
secondary_emails: list[SecondaryEmailOut]
|
||||
|
||||
|
||||
@router.get("/me", response_model=Me)
|
||||
|
|
@ -642,6 +676,10 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
|
|||
"SELECT organization_id::text AS organization_id FROM player_organizations_for_user($1)",
|
||||
user.user_id,
|
||||
)
|
||||
secondary_email_rows = await conn.fetch(
|
||||
"SELECT id::text AS id, email::text AS email FROM user_secondary_email WHERE user_id = $1 ORDER BY created_at",
|
||||
user.user_id,
|
||||
)
|
||||
|
||||
organizations = []
|
||||
for m in membership_rows:
|
||||
|
|
@ -704,6 +742,7 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
|
|||
mobile_number=user_row["mobile_number"],
|
||||
avatar_url=storage.public_url(user_row["avatar_key"]) if user_row["avatar_key"] else None,
|
||||
my_tournaments=my_tournaments,
|
||||
secondary_emails=[SecondaryEmailOut(id=r["id"], email=r["email"]) for r in secondary_email_rows],
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -861,3 +900,121 @@ async def confirm_email_change(body: EmailChangeConfirm) -> Me:
|
|||
"UPDATE app_user SET email = $1 WHERE id = $2", token_row["new_email"], token_row["user_id"]
|
||||
)
|
||||
return await me(CurrentUser(user_id=token_row["user_id"]))
|
||||
|
||||
|
||||
# --- Sekundær e-postadresse (kun det enkle tilfellet -- se FEATURE_BACKLOG.md
|
||||
# "Én person, flere e-postadresser" for hvorfor ekte konto-sammenslåing er
|
||||
# BEVISST utenfor omfang her) -------------------------------------------------
|
||||
# Samme to-stegs bevis-eierskap-mønster som e-postbytte over, men ADDITIVT
|
||||
# (legger til en ny rad i user_secondary_email) i stedet for å erstatte
|
||||
# app_user.email. En verifisert sekundær e-post kan deretter brukes til
|
||||
# innlogging (magic-link OG passord, se verify_magic_link/login_with_password)
|
||||
# -- den løses til DENNE kontoen, aldri til en ny, separat konto.
|
||||
|
||||
|
||||
class SecondaryEmailRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
@router.post("/secondary-email")
|
||||
async def request_secondary_email(
|
||||
body: SecondaryEmailRequest,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> dict:
|
||||
email = body.email.lower()
|
||||
async with plain_connection() as conn:
|
||||
primary = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
|
||||
if primary is not None:
|
||||
raise app_error(
|
||||
409,
|
||||
"DUPLICATE",
|
||||
"Denne adressen er allerede en konto sin hovedadresse. Ekte "
|
||||
"konto-sammenslåing støttes ikke ennå.",
|
||||
)
|
||||
existing_secondary = await conn.fetchval(
|
||||
"SELECT user_id::text FROM user_secondary_email WHERE email = $1", email
|
||||
)
|
||||
if existing_secondary is not None:
|
||||
code = "DUPLICATE" if existing_secondary != user.user_id else "ALREADY_ADDED"
|
||||
raise app_error(
|
||||
409,
|
||||
code,
|
||||
"Denne adressen er allerede lagt til"
|
||||
+ ("på kontoen din." if existing_secondary == user.user_id else " av en annen konto."),
|
||||
)
|
||||
|
||||
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 secondary_email_token (user_id, email, token_hash, expires_at) VALUES ($1, $2, $3, $4)",
|
||||
user.user_id,
|
||||
email,
|
||||
_hash_secret(raw_token),
|
||||
expires_at,
|
||||
)
|
||||
|
||||
if settings.DEV_LOG_MAGIC_LINKS:
|
||||
print(f"[DEV] Sekundær e-post-lenke for {email}: {raw_token}", flush=True)
|
||||
elif settings.SMTP_CONFIGURED:
|
||||
try:
|
||||
await send_secondary_email_verification(email, raw_token)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
class SecondaryEmailConfirm(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
@router.post("/secondary-email/confirm", response_model=Me)
|
||||
async def confirm_secondary_email(body: SecondaryEmailConfirm) -> Me:
|
||||
token_hash = _hash_secret(body.token)
|
||||
async with plain_connection() as conn:
|
||||
token_row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE secondary_email_token
|
||||
SET consumed_at = now()
|
||||
WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now()
|
||||
RETURNING user_id::text AS user_id, email::text AS email
|
||||
""",
|
||||
token_hash,
|
||||
)
|
||||
if token_row is None:
|
||||
raise app_error(401, "INVALID_SECONDARY_EMAIL_LINK", "Lenken er ugyldig, brukt eller utløpt.")
|
||||
|
||||
# Re-sjekk begge unikhetsbetingelsene -- adressen kan ha blitt tatt
|
||||
# (som hovedadresse ELLER som en annen kontos sekundæradresse) i
|
||||
# tiden MELLOM forespørsel og bekreftelse.
|
||||
taken_primary = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", token_row["email"])
|
||||
if taken_primary is not None:
|
||||
raise app_error(409, "DUPLICATE", "Denne adressen ble tatt i bruk av en konto i mellomtiden.")
|
||||
taken_secondary = await conn.fetchval(
|
||||
"SELECT user_id FROM user_secondary_email WHERE email = $1", token_row["email"]
|
||||
)
|
||||
if taken_secondary is not None:
|
||||
raise app_error(409, "DUPLICATE", "Denne adressen ble lagt til av en annen konto i mellomtiden.")
|
||||
|
||||
async with translate_db_errors():
|
||||
await conn.execute(
|
||||
"INSERT INTO user_secondary_email (user_id, email) VALUES ($1, $2)",
|
||||
token_row["user_id"],
|
||||
token_row["email"],
|
||||
)
|
||||
return await me(CurrentUser(user_id=token_row["user_id"]))
|
||||
|
||||
|
||||
@router.delete("/secondary-email/{secondary_email_id}", status_code=204)
|
||||
async def delete_secondary_email(
|
||||
secondary_email_id: str,
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
async with plain_connection() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT user_id::text AS user_id FROM user_secondary_email WHERE id = $1", secondary_email_id
|
||||
)
|
||||
if row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Adressen finnes ikke.")
|
||||
if row["user_id"] != user.user_id:
|
||||
raise app_error(403, "NOT_OWNER", "Denne adressen tilhører ikke deg.")
|
||||
await conn.execute("DELETE FROM user_secondary_email WHERE id = $1", secondary_email_id)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import type React from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Camera, KeyRound, Lock, Mail, ShieldCheck, ShieldOff, User, X } from "lucide-react"
|
||||
import { ArrowLeft, Camera, KeyRound, Lock, Mail, Plus, ShieldCheck, ShieldOff, Trash2, User, X } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
|
@ -31,6 +31,7 @@ type Me = {
|
|||
mobile_country_code: string | null
|
||||
mobile_number: string | null
|
||||
avatar_url: string | null
|
||||
secondary_emails: { id: string; email: string }[]
|
||||
}
|
||||
|
||||
export function AccountSettings() {
|
||||
|
|
@ -105,6 +106,8 @@ export function AccountSettings() {
|
|||
|
||||
<EmailSection email={me.email} />
|
||||
|
||||
<SecondaryEmailSection secondaryEmails={me.secondary_emails} onChanged={loadMe} />
|
||||
|
||||
<PasswordSection hasPassword={me.has_password} onChanged={loadMe} />
|
||||
|
||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
||||
|
|
@ -507,6 +510,152 @@ function EmailSection({ email }: { email: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
// Én person, flere e-postadresser (FEATURE_BACKLOG.md) -- kun det enkle
|
||||
// tilfellet: en FRI, ukrevd adresse legges til og verifiseres, og kan
|
||||
// deretter brukes til innlogging i tillegg til hovedadressen. Ekte
|
||||
// konto-sammenslåing (adressen tilhører allerede en annen konto) er
|
||||
// bevisst IKKE støttet -- backend avviser da med en tydelig 409 DUPLICATE.
|
||||
function SecondaryEmailSection({
|
||||
secondaryEmails,
|
||||
onChanged,
|
||||
}: {
|
||||
secondaryEmails: { id: string; email: string }[]
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [newEmail, setNewEmail] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [removingId, setRemovingId] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch("/auth/secondary-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email: newEmail.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 {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(id: string) {
|
||||
setRemovingId(id)
|
||||
try {
|
||||
const res = await fetch(`/auth/secondary-email/${id}`, { method: "DELETE", credentials: "include" })
|
||||
if (res.ok || res.status === 404) onChanged()
|
||||
} finally {
|
||||
setRemovingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Mail aria-hidden="true" className="size-5 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-base font-bold text-foreground">Andre e-postadresser</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Har du fått en turneringsinvitasjon på en annen adresse enn {""}
|
||||
{"hovedadressen din"}? Legg den til her, så kan du logge inn med begge — og turneringer/
|
||||
data knyttet til den andre adressen dukker opp på kontoen din.
|
||||
</p>
|
||||
|
||||
{secondaryEmails.length > 0 && (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{secondaryEmails.map((se) => (
|
||||
<li
|
||||
key={se.id}
|
||||
className="flex items-center justify-between gap-2 rounded-xl border border-border bg-background px-3 py-2.5"
|
||||
>
|
||||
<span className="truncate text-sm font-semibold text-foreground">{se.email}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={removingId === se.id}
|
||||
onClick={() => handleRemove(se.id)}
|
||||
className="size-8 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
|
||||
aria-label={`Fjern ${se.email}`}
|
||||
>
|
||||
<Trash2 aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{sent ? (
|
||||
<p className="text-sm font-medium text-primary text-pretty">
|
||||
Sjekk innboksen til {newEmail.trim()} — åpne lenken der for å bekrefte at du eier
|
||||
adressen. Ingenting legges til før den er bekreftet.
|
||||
</p>
|
||||
) : adding ? (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<Label htmlFor="secondary-email" className="text-sm font-semibold">
|
||||
Ny adresse
|
||||
</Label>
|
||||
<Input
|
||||
id="secondary-email"
|
||||
type="email"
|
||||
autoFocus
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
className="h-12 rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting || newEmail.trim() === ""}
|
||||
className="h-12 shrink-0 rounded-xl font-semibold"
|
||||
>
|
||||
{submitting ? "Sender …" : "Send bekreftelse"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setAdding(false)}
|
||||
className="h-12 shrink-0 rounded-xl font-semibold"
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAdding(true)}
|
||||
className="h-11 w-fit rounded-xl font-semibold"
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
Legg til adresse
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) {
|
||||
const [password, setPassword] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ type Status = "verifying" | "success" | "error"
|
|||
export function VerifyEmailForm() {
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get("token")
|
||||
// ?kind=secondary (satt av send_secondary_email_verification, app/email.py)
|
||||
// skiller "legg til ekstra adresse" fra det vanlige "bytt hovedadresse"-
|
||||
// løpet -- samme sides UI, ulikt endepunkt/budskap.
|
||||
const isSecondary = searchParams.get("kind") === "secondary"
|
||||
const [status, setStatus] = useState<Status>(token ? "verifying" : "error")
|
||||
const [email, setEmail] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -21,7 +25,7 @@ export function VerifyEmailForm() {
|
|||
}
|
||||
async function verify() {
|
||||
try {
|
||||
const res = await fetch("/auth/profile/email/confirm", {
|
||||
const res = await fetch(isSecondary ? "/auth/secondary-email/confirm" : "/auth/profile/email/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
|
|
@ -31,8 +35,10 @@ export function VerifyEmailForm() {
|
|||
const body = await res.json().catch(() => null)
|
||||
throw new Error(body?.detail?.message ?? "Lenken er ugyldig eller utløpt.")
|
||||
}
|
||||
const result: { email: string } = await res.json()
|
||||
setEmail(result.email)
|
||||
if (!isSecondary) {
|
||||
const result: { email: string } = await res.json()
|
||||
setEmail(result.email)
|
||||
}
|
||||
setStatus("success")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
|
||||
|
|
@ -40,7 +46,7 @@ export function VerifyEmailForm() {
|
|||
}
|
||||
}
|
||||
void verify()
|
||||
}, [token])
|
||||
}, [token, isSecondary])
|
||||
|
||||
if (status === "verifying") {
|
||||
return (
|
||||
|
|
@ -63,11 +69,20 @@ export function VerifyEmailForm() {
|
|||
<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">E-post oppdatert</h2>
|
||||
{email && (
|
||||
<h2 className="text-2xl font-extrabold tracking-tight text-balance">
|
||||
{isSecondary ? "Adresse lagt til" : "E-post oppdatert"}
|
||||
</h2>
|
||||
{isSecondary ? (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Du logger nå inn med <span className="font-semibold text-foreground">{email}</span>.
|
||||
Adressen er nå knyttet til kontoen din — du kan logge inn med den i tillegg til
|
||||
hovedadressen.
|
||||
</p>
|
||||
) : (
|
||||
email && (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Du logger nå inn med <span className="font-semibold text-foreground">{email}</span>.
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
|
|
|
|||
Loading…
Reference in a new issue