Update Todos

Legge til argon2-cffi, pyotp, qrcode i requirements.txt

Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)

app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere

app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering

app/email.py: 2FA-kode og invitasjons-maler

app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti

Frontend: login-form passord-modus + 2FA-skjermer

Frontend: kontoinnstillinger + org-medlemsstyring-skjerm

Ekte typesjekket frontend-build

Scratch-verifisere hele auth-løpet grundig (backend)

Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.

Klar til utrulling mot ekte systemer:

Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
This commit is contained in:
Erol Haagenrud 2026-07-19 10:36:50 +02:00
parent 73f3c4d1ea
commit 89a803140e
15 changed files with 2327 additions and 80 deletions

View file

@ -0,0 +1,115 @@
-- =====================================================================
-- TeeCup — migrasjon 012
-- Passord (valgfritt tillegg) + 2FA (ADR-021), organisasjonsinvitasjoner
-- + superadmin (ADR-022)
-- =====================================================================
-- Kjøres etter 001-011.
-- =====================================================================
\set ON_ERROR_STOP on
-- ---------------------------------------------------------------------
-- 1. Passord (ADR-021 Beslutning A/B) -- Argon2id, ALDRI påkrevd.
-- NULL betyr "ikke satt" -- brukeren har da fortsatt kun magic-link.
-- ---------------------------------------------------------------------
ALTER TABLE app_user ADD COLUMN password_hash text;
-- ---------------------------------------------------------------------
-- 2. 2FA (ADR-021 Beslutning C/D) -- TOTP eller e-post-engangskode,
-- brukerens eget valg. totp_secret er kun satt når metoden er 'totp'.
-- ---------------------------------------------------------------------
ALTER TABLE app_user ADD COLUMN two_factor_method text
CHECK (two_factor_method IN ('totp', 'email'));
ALTER TABLE app_user ADD COLUMN totp_secret text;
ALTER TABLE app_user ADD CONSTRAINT app_user_totp_secret_requires_totp_method
CHECK (totp_secret IS NULL OR two_factor_method = 'totp');
-- Engangskoder for e-post-2FA. Samme hash-og-utløp-mønster som
-- magic_link_token (004), men knyttet til user_id (identiteten er allerede
-- bevist av primær-autentiseringen på dette tidspunktet).
CREATE TABLE two_factor_code (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
code_hash text NOT NULL,
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON two_factor_code (user_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON two_factor_code TO teecup_app;
-- ---------------------------------------------------------------------
-- 3. Superadmin (ADR-022 Beslutning D) -- KUN manuelt DB-tildelt.
-- Bevisst INGEN API-endepunkt setter/gir dette flagget -- se ADR-022
-- for hvorfor et selvbetjent "bli superadmin"-endepunkt ville vært
-- selve sikkerhetshullet.
-- ---------------------------------------------------------------------
ALTER TABLE app_user ADD COLUMN is_super_admin boolean NOT NULL DEFAULT false;
-- ---------------------------------------------------------------------
-- 4. Organisasjonsinvitasjoner (ADR-022 Beslutning B) -- e-post +
-- tiltenkt rolle. INGEN egen klikkbar token/lenke -- aksept skjer
-- automatisk ved neste innlogging med matchende e-post (samme mønster
-- som spiller-e-post-kobling, ADR-017 Beslutning B), ikke et eget
-- verifiseringstrinn.
-- ---------------------------------------------------------------------
CREATE TABLE organization_invitation (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id uuid NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
email citext NOT NULL,
role text NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
invited_by uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Kun én AKTIV (uforbrukt) invitasjon per e-post per organisasjon.
CREATE UNIQUE INDEX organization_invitation_active_unique
ON organization_invitation (organization_id, email) WHERE consumed_at IS NULL;
CREATE INDEX ON organization_invitation (email);
ALTER TABLE organization_invitation ENABLE ROW LEVEL SECURITY;
ALTER TABLE organization_invitation FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON organization_invitation
USING (organization_id = app_current_org())
WITH CHECK (organization_id = app_current_org());
GRANT SELECT, INSERT, UPDATE, DELETE ON organization_invitation TO teecup_app;
-- ---------------------------------------------------------------------
-- 5. accept_pending_invitations_by_email() -- femte SECURITY DEFINER-bro
-- i prosjektet (etter public_tournament_org 007, link_player_by_email
-- 008, public_org_by_slug 009, public_tournament_by_code 011). Kjøres
-- på HVER innlogging (magic-link OG passord), samme idempotente,
-- "berører kun uforbrukte/gyldige rader"-mønster som
-- link_player_by_email -- gjentatte kall er en no-op.
--
-- organization_membership har INGEN RLS (se 001 -- "håndteres av
-- auth-laget"), så selve INSERT-en trenger ikke SECURITY DEFINER for
-- å krysse org-grenser -- men organization_invitation (som denne
-- leser fra) HAR RLS, og en bruker som blir invitert er per
-- definisjon IKKE medlem av org-en ennå (kan derfor ikke ha
-- app.current_org satt til den). SECURITY DEFINER løser dette
-- kontekst-problemet på nøyaktig samme måte som de fire tidligere
-- broene.
-- ---------------------------------------------------------------------
CREATE FUNCTION accept_pending_invitations_by_email(p_user_id uuid, p_email text)
RETURNS void
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
WITH accepted AS (
UPDATE organization_invitation
SET consumed_at = now()
WHERE consumed_at IS NULL
AND expires_at > now()
AND lower(email) = lower(p_email)
RETURNING organization_id, role
)
INSERT INTO organization_membership (organization_id, user_id, role)
SELECT organization_id, p_user_id, role FROM accepted
ON CONFLICT (organization_id, user_id) DO NOTHING;
$$;
GRANT EXECUTE ON FUNCTION accept_pending_invitations_by_email(uuid, text) TO teecup_app;

View file

@ -1,10 +1,10 @@
""" """
Auth-lag for TeeCup. Auth-lag for TeeCup.
Ekte autentisering: magic-link (se app/routers/auth.py for utstedelse) + Ekte autentisering: magic-link ELLER e-post+passord (ADR-021, begge fører til
JWT-sesjon i en HttpOnly-cookie. `get_current_user` er IKKE lenger en stub samme sesjons-cookie), pluss valgfri/påkrevd 2FA (TOTP eller e-post-
den dekoder og verifiserer den innsendte sesjonscookien, og bekrefter at engangskode). `get_current_user` dekoder og verifiserer den innsendte
brukeren fortsatt finnes. sesjonscookien, og bekrefter at brukeren fortsatt finnes.
`get_authorized_org` er fortsatt sikkerhetskritisk uendret: den verifiserer at `get_authorized_org` er fortsatt sikkerhetskritisk uendret: den verifiserer at
den innloggede brukeren faktisk er medlem av organisasjonen før org-konteksten den innloggede brukeren faktisk er medlem av organisasjonen før org-konteksten
@ -14,12 +14,26 @@ Hvorfor det er kritisk: RLS stoler blindt på `app.current_org`. Setter appen de
til en organisasjon brukeren ikke tilhører, gir RLS lydig tilgang til den til en organisasjon brukeren ikke tilhører, gir RLS lydig tilgang til den
organisasjonens data. Isolasjonen står og faller altså at denne sjekken skjer organisasjonens data. Isolasjonen står og faller altså at denne sjekken skjer
FØR org_connection kalles aldri sett konteksten fra en uverifisert kilde. FØR org_connection kalles aldri sett konteksten fra en uverifisert kilde.
Sesjons-STADIER (ADR-021 Beslutning E): en sesjonscookie er ikke nødvendigvis
en FULL sesjon. Når 2FA kreves, utstedes et kortlevd JWT med
`stage: "pending_2fa"` eller `"must_enroll_2fa"` i stedet for en full
30-dagers sesjon -- `get_current_user` (brukt av ALLE vanlige endepunkter)
avviser eksplisitt alt annet enn `stage: "full"` (eller en ELDRE token uten
noe stage-felt i det hele tatt, utstedt før denne runden -- behandles som
"full" for bakoverkompatibilitet, ingen eksisterende bruker logges brått ut).
De egne 2FA-endepunktene bruker i stedet `get_pending_user`, som KUN godtar
en mellomtilstand.
""" """
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal
import jwt import jwt
import pyotp
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHash
from fastapi import Depends, Request from fastapi import Depends, Request
from .config import settings from .config import settings
@ -30,18 +44,74 @@ SESSION_COOKIE_NAME = "teecup_session"
_JWT_ALGORITHM = "HS256" # Eksplisitt både ved signering og dekoding -- blokkerer _JWT_ALGORITHM = "HS256" # Eksplisitt både ved signering og dekoding -- blokkerer
# alg-forvirring/"alg:none"-angrep. # alg-forvirring/"alg:none"-angrep.
Stage = Literal["full", "pending_2fa", "must_enroll_2fa"]
_PENDING_STAGE_MAX_AGE_SECONDS = 5 * 60 # kort levetid -- kun for å fullføre 2FA
@dataclass(frozen=True) @dataclass(frozen=True)
class CurrentUser: class CurrentUser:
user_id: str user_id: str
def create_session_token(user_id: str) -> str: @dataclass(frozen=True)
class PendingUser:
"""En bruker som har bevist primær-identitet (magic-link/passord), men
IKKE fullført 2FA ennå -- se modul-docstring."""
user_id: str
stage: Stage
def create_session_token(user_id: str, stage: Stage = "full") -> str:
now = int(time.time()) now = int(time.time())
payload = {"sub": user_id, "iat": now, "exp": now + settings.SESSION_MAX_AGE_SECONDS} max_age = settings.SESSION_MAX_AGE_SECONDS if stage == "full" else _PENDING_STAGE_MAX_AGE_SECONDS
payload: dict = {"sub": user_id, "iat": now, "exp": now + max_age}
if stage != "full":
payload["stage"] = stage
return jwt.encode(payload, settings.SESSION_SECRET, algorithm=_JWT_ALGORITHM) return jwt.encode(payload, settings.SESSION_SECRET, algorithm=_JWT_ALGORITHM)
# ---------------------------------------------------------------------------
# Passord (ADR-021 Beslutning B) -- Argon2id, ikke bcrypt (unngår 72-byte-
# trunkering, viktig siden spesialtegn/mellomrom skal fungere korrekt).
# ---------------------------------------------------------------------------
_password_hasher = PasswordHasher()
def hash_password(raw_password: str) -> str:
return _password_hasher.hash(raw_password)
def verify_password(raw_password: str, password_hash: str) -> bool:
try:
_password_hasher.verify(password_hash, raw_password)
return True
except (VerifyMismatchError, InvalidHash):
return False
# ---------------------------------------------------------------------------
# TOTP (ADR-021 Beslutning C) -- RFC 6238, virker med enhver standard
# autentisator-app (Google Authenticator/Authy/1Password osv.).
# ---------------------------------------------------------------------------
def generate_totp_secret() -> str:
return pyotp.random_base32()
def totp_provisioning_uri(secret: str, email: str) -> str:
return pyotp.totp.TOTP(secret).provisioning_uri(name=email, issuer_name="TeeCup")
def verify_totp_code(secret: str, code: str) -> bool:
# valid_window=1 tillater ett 30-sekunders steg klokkedrift hver vei --
# standard toleranse, unngår at en litt ute-av-synk klokke på
# brukerens enhet gir falske avvisninger.
return pyotp.TOTP(secret).verify(code, valid_window=1)
def should_use_secure_cookies(request: Request) -> bool: def should_use_secure_cookies(request: Request) -> bool:
"""Secure-flagget skal kun være sant over https. """Secure-flagget skal kun være sant over https.
@ -60,6 +130,10 @@ async def get_current_user(request: Request) -> CurrentUser:
Det siste steget (et ekte DB-oppslag, ikke bare å stole JWT-en) gir Det siste steget (et ekte DB-oppslag, ikke bare å stole JWT-en) gir
faktisk tilbakekalling: en slettet/deaktivert bruker kan ikke ri ut faktisk tilbakekalling: en slettet/deaktivert bruker kan ikke ri ut
resten av sesjonens levetid en ellers gyldig token. resten av sesjonens levetid en ellers gyldig token.
Avviser eksplisitt alt annet enn `stage: "full"` -- en `pending_2fa`-
eller `must_enroll_2fa`-token gir ALDRI tilgang her, kun via
`get_pending_user` de egne 2FA-endepunktene (se modul-docstring).
""" """
token = request.cookies.get(SESSION_COOKIE_NAME) token = request.cookies.get(SESSION_COOKIE_NAME)
if not token: if not token:
@ -72,6 +146,8 @@ async def get_current_user(request: Request) -> CurrentUser:
user_id = claims.get("sub") user_id = claims.get("sub")
if not user_id: if not user_id:
raise app_error(401, "NOT_AUTHENTICATED", "Ugyldig sesjon.") raise app_error(401, "NOT_AUTHENTICATED", "Ugyldig sesjon.")
if claims.get("stage", "full") != "full":
raise app_error(401, "NOT_AUTHENTICATED", "Innloggingen er ikke fullført (2FA gjenstår).")
async with plain_connection() as conn: async with plain_connection() as conn:
exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM app_user WHERE id = $1)", user_id) exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM app_user WHERE id = $1)", user_id)
@ -81,6 +157,91 @@ async def get_current_user(request: Request) -> CurrentUser:
return CurrentUser(user_id=user_id) return CurrentUser(user_id=user_id)
async def get_pending_user(request: Request) -> PendingUser:
"""Som get_current_user, men KUN for en mellomtilstand (2FA gjenstår).
Brukt utelukkende av 2FA-verifiserings-/oppsett-endepunktene
(app/routers/auth.py) -- avviser en FULL sesjon like strengt som en
manglende en, siden dette endepunktet ikke gir mening for en allerede
ferdig innlogget bruker (de bruker de vanlige kontoinnstillings-
endepunktene for frivillig 2FA-oppsett i stedet, se
get_current_or_enrolling_user)."""
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
raise app_error(401, "NOT_AUTHENTICATED", "Ikke innlogget.")
try:
claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM])
except jwt.PyJWTError:
raise app_error(401, "NOT_AUTHENTICATED", "Ugyldig eller utløpt sesjon.")
user_id = claims.get("sub")
stage = claims.get("stage", "full")
if not user_id or stage == "full":
raise app_error(401, "NOT_AUTHENTICATED", "Ingen 2FA-verifisering pågår.")
async with plain_connection() as conn:
exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM app_user WHERE id = $1)", user_id)
if not exists:
raise app_error(401, "NOT_AUTHENTICATED", "Brukeren finnes ikke lenger.")
return PendingUser(user_id=user_id, stage=stage)
async def get_current_or_enrolling_user(request: Request) -> CurrentUser:
"""Godtar BÅDE en full sesjon (frivillig 2FA-oppsett fra
kontoinnstillinger) OG `must_enroll_2fa` (tvungen oppsett rett etter
innlogging, ADR-021 Beslutning D) -- samme oppsett-endepunkter dekker
begge veiene inn, siden selve oppsett-logikken er identisk."""
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
raise app_error(401, "NOT_AUTHENTICATED", "Ikke innlogget.")
try:
claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM])
except jwt.PyJWTError:
raise app_error(401, "NOT_AUTHENTICATED", "Ugyldig eller utløpt sesjon.")
user_id = claims.get("sub")
stage = claims.get("stage", "full")
if not user_id or stage not in ("full", "must_enroll_2fa"):
raise app_error(401, "NOT_AUTHENTICATED", "Ugyldig sesjon.")
async with plain_connection() as conn:
exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM app_user WHERE id = $1)", user_id)
if not exists:
raise app_error(401, "NOT_AUTHENTICATED", "Brukeren finnes ikke lenger.")
return CurrentUser(user_id=user_id)
async def user_requires_2fa_enrollment(conn, user_id: str) -> bool:
"""ADR-021 Beslutning D: org-eier/admin UTEN 2FA konfigurert må sette
det opp FØR de får en full sesjon. organization_membership har ingen
RLS (se 001), dette fungerer en plain_connection()."""
return await conn.fetchval(
"""
SELECT (two_factor_method IS NULL) AND EXISTS (
SELECT 1 FROM organization_membership
WHERE user_id = $1 AND role IN ('owner', 'admin')
)
FROM app_user WHERE id = $1
""",
user_id,
)
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
settbart via noe API-endepunkt. Denne avhengigheten sjekker bare
flagget -- den gir ikke selv noen vei til å endre det."""
async with plain_connection() as conn:
is_super_admin = await conn.fetchval(
"SELECT is_super_admin FROM app_user WHERE id = $1", user.user_id
)
if not is_super_admin:
raise app_error(403, "NOT_SUPER_ADMIN", "Krever superadmin-rettigheter.")
return user
async def get_current_user_optional(request: Request) -> CurrentUser | None: async def get_current_user_optional(request: Request) -> CurrentUser | None:
"""Som get_current_user, men returnerer None i stedet for å kaste 401. """Som get_current_user, men returnerer None i stedet for å kaste 401.
@ -97,7 +258,7 @@ async def get_current_user_optional(request: Request) -> CurrentUser | None:
return None return None
user_id = claims.get("sub") user_id = claims.get("sub")
if not user_id: if not user_id or claims.get("stage", "full") != "full":
return None return None
async with plain_connection() as conn: async with plain_connection() as conn:

View file

@ -77,3 +77,71 @@ async def send_magic_link_email(to_email: str, raw_token: str, locale: str = "nb
minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, token=raw_token, link=link minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, token=raw_token, link=link
) )
await to_thread(_send_sync, to_email, subject, body) 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)

View file

@ -8,3 +8,6 @@ pillow>=10.0
pillow-avif-plugin>=1.4 pillow-avif-plugin>=1.4
python-multipart>=0.0.9 python-multipart>=0.0.9
httpx>=0.27 httpx>=0.27
argon2-cffi>=23.1
pyotp>=2.9
qrcode>=7.4

View file

@ -1,43 +1,70 @@
""" """
Autentisering: magic-link-forespørsel/-verifisering, utlogging, "hvem er jeg". Autentisering: magic-link/passord-forespørsel/-verifisering, 2FA, utlogging,
"hvem er jeg" (ADR-009, ADR-021).
Sikkerhetsmønster (se plan/ADR-009): Sikkerhetsmønster (se ADR-009/ADR-021):
- request-link svarer ALLTID identisk, uansett om e-posten finnes eller - request-link svarer ALLTID identisk, uansett om e-posten finnes eller
nettopp fikk en lenke -- unngår at endepunktet kan brukes til å sjekke nettopp fikk en lenke -- unngår at endepunktet kan brukes til å sjekke
hvilke e-poster som har konto (enumerering). hvilke e-poster som har konto (enumerering). login-password svarer
- Kun SHA-256-hashen av token lagres, aldri klarteksten. identisk (401 INVALID_CREDENTIALS) uansett om e-posten finnes, mangler
- app_user opprettes FØRST når en gyldig, uforbrukt token løses inn i passord, eller passordet er feil -- samme prinsipp.
verify-link -- IKKE når lenken bare forespørres (hindrer massopprettelse - Kun SHA-256-hashen av magic-link-token/2FA-kode lagres, aldri klartekst.
av kontoer for e-poster man ikke eier). Passord hashes med Argon2id (app/auth.py sin hash_password), ikke
- Token-forbruk er ÉN atomisk UPDATE ... RETURNING (ikke les-sjekk-skriv) SHA-256 -- se ADR-021 Beslutning B.
for å hindre at to samtidige forsøk med samme lenke begge lykkes. - app_user opprettes FØRST når en gyldig, uforbrukt magic-link-token løses
inn (IKKE når lenken bare forespørres) -- hindrer massopprettelse av
kontoer for e-poster man ikke eier. Passord kan kun SETTES av en
allerede innlogget bruker (set-password), aldri ved kontoopprettelse.
- Token-/kode-forbruk er ÉN atomisk UPDATE ... RETURNING (ikke
les-sjekk-skriv) for å hindre at to samtidige forsøk begge lykkes.
- Levering: ekte SMTP-utsending (app/email.py) hvis settings.SMTP_CONFIGURED, - Levering: ekte SMTP-utsending (app/email.py) hvis settings.SMTP_CONFIGURED,
ellers dev-only konsoll-logging bak settings.DEV_LOG_MAGIC_LINKS (aldri ellers dev-only konsoll-logging bak settings.DEV_LOG_MAGIC_LINKS (aldri
som standard). En driftsfeil i selve utsendingen (feil passord, SMTP nede, som standard). En driftsfeil i selve utsendingen logges server-side og
eller ingen av delene konfigurert) logges server-side og lekker ALDRI til lekker ALDRI til klienten som en annen respons.
klienten som en annen respons -- det ville brutt enumereringsvernet over
(klienten skal ikke kunne skille "finnes ikke" fra "utsending feilet").
i18n (se plan): `locale` sendes av klienten ved forespørsel (frontend vet sitt To-stegs innlogging (ADR-021 Beslutning D/E): primær-autentisering
eget visningsspråk), lagres token-raden, og brukes BÅDE til å velge språk (verify-link ELLER login-password) utsteder ALDRI direkte en full sesjon --
selve e-posten OG til å sette en HELT NY brukers `preferred_locale`. En den går alltid via `_issue_login_result()`, som avgjør om 2FA kreves
EKSISTERENDE bruker som ber om en ny lenke får IKKE sin lagrede preferanse (brukeren har selv slått det ) eller settes opp FØRST (org-eier/admin
overskrevet -- kun e-posten sendes forespørselens språk. uten 2FA konfigurert), og utsteder riktig sesjons-STADIE (se app/auth.py).
i18n (se ADR-015): `locale` sendes av klienten ved forespørsel, lagres
token-raden, og brukes BÅDE til å velge språk selve e-posten OG til å
sette en HELT NY brukers `preferred_locale`. En EKSISTERENDE bruker som ber
om en ny lenke får IKKE sin lagrede preferanse overskrevet.
""" """
import base64
import hashlib import hashlib
import secrets import secrets
import traceback import traceback
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from io import BytesIO
from typing import Literal from typing import Literal
import qrcode
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr, Field
from ..auth import CurrentUser, SESSION_COOKIE_NAME, create_session_token, get_current_user, should_use_secure_cookies from ..auth import (
CurrentUser,
PendingUser,
SESSION_COOKIE_NAME,
create_session_token,
generate_totp_secret,
get_current_or_enrolling_user,
get_current_user,
get_pending_user,
hash_password,
should_use_secure_cookies,
totp_provisioning_uri,
user_requires_2fa_enrollment,
verify_password,
verify_totp_code,
)
from ..config import settings from ..config import settings
from ..db import org_connection, plain_connection from ..db import org_connection, plain_connection
from ..email import send_magic_link_email from ..email import send_magic_link_email, send_two_factor_code_email
from ..errors import app_error from ..errors import app_error
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@ -48,8 +75,32 @@ _GENERIC_RESPONSE = {
} }
def _hash_token(token: str) -> str: def _hash_secret(raw: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest() """Generisk SHA-256-hash -- brukt for BÅDE magic-link-tokens og
2FA-engangskoder (begge er kortlevde, engangs hemmeligheter av samme
trusselklasse). Passord bruker Argon2id i stedet (app/auth.py)."""
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _set_session_cookie(response: Response, request: Request, token: str, stage: str) -> None:
max_age = settings.SESSION_MAX_AGE_SECONDS if stage == "full" else 5 * 60
response.set_cookie(
SESSION_COOKIE_NAME,
token,
max_age=max_age,
httponly=True,
samesite="lax",
secure=should_use_secure_cookies(request),
path="/",
)
def _build_qr_data_uri(data: str) -> str:
img = qrcode.make(data)
buf = BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:image/png;base64,{b64}"
class MagicLinkRequest(BaseModel): class MagicLinkRequest(BaseModel):
@ -86,7 +137,7 @@ async def request_magic_link(body: MagicLinkRequest) -> dict:
await conn.execute( await conn.execute(
"INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, $4)", "INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, $4)",
email, email,
_hash_token(raw_token), _hash_secret(raw_token),
expires_at, expires_at,
body.locale, body.locale,
) )
@ -122,11 +173,60 @@ class SessionUser(BaseModel):
preferred_locale: str preferred_locale: str
@router.post("/verify-link", response_model=SessionUser) class LoginResult(BaseModel):
async def verify_magic_link( """Felles svarform for verify-link, login-password OG 2fa/verify (ADR-021
body: MagicLinkVerify, response: Response, request: Request Beslutning E) -- akkurat ÉN av de tre utfallene:
) -> SessionUser: - "success": full sesjon utstedt, `user` er fylt ut.
token_hash = _hash_token(body.token) - "2fa_required": brukeren har selv slått 2FA -- `two_factor_method`
forteller klienten hvilken kode-innsending som trengs.
- "2fa_setup_required": org-eier/admin uten 2FA -- klienten vise
oppsett-flyten FØR noe annet blir tilgjengelig."""
status: Literal["success", "2fa_required", "2fa_setup_required"]
user: SessionUser | None = None
two_factor_method: Literal["totp", "email"] | None = None
async def _issue_login_result(response: Response, request: Request, user_id: str) -> LoginResult:
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"""
SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
two_factor_method
FROM app_user WHERE id = $1
""",
user_id,
)
needs_enrollment = await user_requires_2fa_enrollment(conn, user_id)
two_factor_method = user_row["two_factor_method"]
if two_factor_method:
token = create_session_token(user_id, stage="pending_2fa")
_set_session_cookie(response, request, token, "pending_2fa")
return LoginResult(status="2fa_required", two_factor_method=two_factor_method)
if needs_enrollment:
token = create_session_token(user_id, stage="must_enroll_2fa")
_set_session_cookie(response, request, token, "must_enroll_2fa")
return LoginResult(status="2fa_setup_required")
token = create_session_token(user_id, stage="full")
_set_session_cookie(response, request, token, "full")
return LoginResult(
status="success",
user=SessionUser(
id=user_row["id"],
email=user_row["email"],
display_name=user_row["display_name"],
preferred_locale=user_row["preferred_locale"],
),
)
@router.post("/verify-link", response_model=LoginResult)
async def verify_magic_link(body: MagicLinkVerify, response: Response, request: Request) -> LoginResult:
token_hash = _hash_secret(body.token)
async with plain_connection() as conn: async with plain_connection() as conn:
# Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to # Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to
# samtidige forsøk med samme lenke kan da ikke begge lykkes. # samtidige forsøk med samme lenke kan da ikke begge lykkes.
@ -161,34 +261,288 @@ async def verify_magic_link(
# her, kun ved førstegangsopprettelse (se modul-docstring). # her, kun ved førstegangsopprettelse (se modul-docstring).
user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email) user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email)
# Koble enhver player-rad (i HVILKEN SOM HELST org, uansett # Koble enhver player-rad (ADR-017) OG enhver ventende
# medlemskap) med matchende e-post til denne nå-innloggede kontoen # organisasjonsinvitasjon (ADR-022) med matchende e-post til denne
# (ADR-017 Beslutning B). Trygt å kjøre på HVER innlogging, ikke # nå-innloggede kontoen. Trygt å kjøre på HVER innlogging -- begge
# bare førstegangsopprettelse -- funksjonens WHERE-ledd berører kun # er idempotente, berører kun rader som ennå ikke er koblet/forbrukt.
# rader som ennå ikke er koblet, så gjentatte kall er en no-op.
await conn.execute("SELECT link_player_by_email($1, $2)", user_id, email) await conn.execute("SELECT link_player_by_email($1, $2)", user_id, email)
await conn.execute("SELECT accept_pending_invitations_by_email($1, $2)", user_id, email)
# user_id er en asyncpg UUID her (ingen ::text-cast i spørringene over) --
# create_session_token() JSON-serialiserer JWT-payloaden, som krasjer på
# en rå UUID. Fant under scratch-testing: str() FØR den sendes videre.
return await _issue_login_result(response, request, str(user_id))
class PasswordLoginRequest(BaseModel):
email: EmailStr
password: str
@router.post("/login-password", response_model=LoginResult)
async def login_with_password(body: PasswordLoginRequest, response: Response, request: Request) -> LoginResult:
email = body.email.lower()
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"SELECT id::text AS id, password_hash FROM app_user WHERE email = $1", email
)
# Identisk feil uansett årsak (e-post finnes ikke / intet passord satt /
# feil passord) -- unngår enumerering, samme prinsipp som magic-link.
if (
user_row is None
or user_row["password_hash"] is None
or not verify_password(body.password, user_row["password_hash"])
):
raise app_error(401, "INVALID_CREDENTIALS", "E-post eller passord er feil.")
await conn.execute("SELECT link_player_by_email($1, $2)", user_row["id"], email)
await conn.execute("SELECT accept_pending_invitations_by_email($1, $2)", user_row["id"], email)
return await _issue_login_result(response, request, user_row["id"])
class SetPasswordRequest(BaseModel):
# Bevisst INGEN restriksjon på tegnsett -- spesialtegn/mellomrom skal
# fungere korrekt (Argon2id, se app/auth.py). max_length er kun en
# fornuftig DoS-grense mot ekstremt lange innsendinger, ikke en reell
# begrensning på passfraser.
password: str = Field(min_length=8, max_length=200)
@router.post("/set-password")
async def set_password(body: SetPasswordRequest, user: CurrentUser = Depends(get_current_user)) -> dict:
hashed = hash_password(body.password)
async with plain_connection() as conn:
await conn.execute("UPDATE app_user SET password_hash = $1 WHERE id = $2", hashed, user.user_id)
return {"status": "ok"}
@router.post("/remove-password")
async def remove_password(user: CurrentUser = Depends(get_current_user)) -> dict:
async with plain_connection() as conn:
await conn.execute("UPDATE app_user SET password_hash = NULL WHERE id = $1", user.user_id)
return {"status": "ok"}
# ---------------------------------------------------------------------------
# 2FA: verifisering av en PÅGÅENDE innlogging (ADR-021 Beslutning C/E)
# ---------------------------------------------------------------------------
async def _send_two_factor_email_code(conn, user_id: str, email: str, locale: str) -> None:
# Ugyldiggjør tidligere uforbrukte koder for denne brukeren -- samme
# "begrens vinduet en gammel kode kan misbrukes i"-mønster som magic-link.
await conn.execute(
"UPDATE two_factor_code SET consumed_at = now() WHERE user_id = $1 AND consumed_at IS NULL",
user_id,
)
code = f"{secrets.randbelow(1_000_000):06d}"
await conn.execute(
"INSERT INTO two_factor_code (user_id, code_hash, expires_at) VALUES ($1, $2, $3)",
user_id,
_hash_secret(code),
datetime.now(timezone.utc) + timedelta(minutes=5),
)
if settings.DEV_LOG_MAGIC_LINKS:
print(f"[DEV] 2FA-kode for {email}: {code}", flush=True)
elif settings.SMTP_CONFIGURED:
try:
await send_two_factor_code_email(email, code, locale)
except Exception:
traceback.print_exc()
@router.post("/2fa/email/request")
async def request_two_factor_email_code(pending: PendingUser = Depends(get_pending_user)) -> dict:
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"SELECT email::text AS email, preferred_locale, two_factor_method FROM app_user WHERE id = $1",
pending.user_id,
)
if user_row["two_factor_method"] != "email":
raise app_error(400, "VALIDATION_FAILED", "Denne kontoen bruker ikke e-post som 2FA-metode.")
await _send_two_factor_email_code(conn, pending.user_id, user_row["email"], user_row["preferred_locale"])
return {"status": "sent"}
class TwoFactorVerify(BaseModel):
code: str
@router.post("/2fa/verify", response_model=LoginResult)
async def verify_two_factor(
body: TwoFactorVerify,
response: Response,
request: Request,
pending: PendingUser = Depends(get_pending_user),
) -> LoginResult:
async with plain_connection() as conn:
method = await conn.fetchval(
"SELECT two_factor_method FROM app_user WHERE id = $1", pending.user_id
)
valid = False
if method == "totp":
secret = await conn.fetchval(
"SELECT totp_secret FROM app_user WHERE id = $1", pending.user_id
)
valid = secret is not None and verify_totp_code(secret, body.code.strip())
elif method == "email":
row = await conn.fetchrow(
"""
UPDATE two_factor_code SET consumed_at = now()
WHERE user_id = $1 AND code_hash = $2 AND consumed_at IS NULL AND expires_at > now()
RETURNING id
""",
pending.user_id,
_hash_secret(body.code.strip()),
)
valid = row is not None
if not valid:
raise app_error(401, "INVALID_2FA_CODE", "Feil eller utløpt kode.")
# Utsted en FULL sesjon direkte -- IKKE via _issue_login_result(), som
# ville sett two_factor_method fortsatt satt og krevd EN NY runde med
# 2FA for akkurat denne innloggingen (uendelig løkke). Samme fiks som
# 2fa/setup/confirm, se kommentaren der -- fant begge under
# scratch-testing.
async with plain_connection() as conn:
user_row = await conn.fetchrow( user_row = await conn.fetchrow(
""" """
SELECT id::text AS id, email::text AS email, display_name, preferred_locale SELECT id::text AS id, email::text AS email, display_name, preferred_locale
FROM app_user WHERE id = $1 FROM app_user WHERE id = $1
""", """,
user_id, pending.user_id,
)
token = create_session_token(pending.user_id, stage="full")
_set_session_cookie(response, request, token, "full")
return LoginResult(
status="success",
user=SessionUser(
id=user_row["id"],
email=user_row["email"],
display_name=user_row["display_name"],
preferred_locale=user_row["preferred_locale"],
),
) )
token = create_session_token(user_row["id"])
response.set_cookie( # ---------------------------------------------------------------------------
SESSION_COOKIE_NAME, # 2FA: OPPSETT (frivillig fra kontoinnstillinger, ELLER tvunget for
token, # org-eier/admin -- begge veier deler samme logikk, se
max_age=settings.SESSION_MAX_AGE_SECONDS, # get_current_or_enrolling_user i app/auth.py)
httponly=True, # ---------------------------------------------------------------------------
samesite="lax",
secure=should_use_secure_cookies(request),
path="/", class TwoFactorSetupStart(BaseModel):
method: Literal["totp", "email"]
class TwoFactorSetupStartResult(BaseModel):
status: Literal["totp_ready", "email_sent"]
secret: str | None = None
otpauth_uri: str | None = None
qr_code_data_uri: str | None = None
@router.post("/2fa/setup/start", response_model=TwoFactorSetupStartResult)
async def start_two_factor_setup(
body: TwoFactorSetupStart, user: CurrentUser = Depends(get_current_or_enrolling_user)
) -> TwoFactorSetupStartResult:
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"SELECT email::text AS email, preferred_locale FROM app_user WHERE id = $1", user.user_id
) )
if body.method == "totp":
# Ingenting lagres ennå -- secreten rundtures til klienten og
# ekkoes tilbake i /2fa/setup/confirm, som er FØRSTE gang noe
# persisteres. Unngår en halvferdig 2FA-tilstand i databasen
# hvis brukeren forlater oppsettet uten å fullføre.
secret = generate_totp_secret()
uri = totp_provisioning_uri(secret, user_row["email"] or user.user_id)
return TwoFactorSetupStartResult(
status="totp_ready",
secret=secret,
otpauth_uri=uri,
qr_code_data_uri=_build_qr_data_uri(uri),
)
await _send_two_factor_email_code(conn, user.user_id, user_row["email"], user_row["preferred_locale"])
return TwoFactorSetupStartResult(status="email_sent")
class TwoFactorSetupConfirm(BaseModel):
method: Literal["totp", "email"]
code: str
# Kun for totp -- klienten ekkoer secreten fra /2fa/setup/start tilbake,
# se kommentaren der.
secret: str | None = None
@router.post("/2fa/setup/confirm", response_model=SessionUser)
async def confirm_two_factor_setup(
body: TwoFactorSetupConfirm,
response: Response,
request: Request,
user: CurrentUser = Depends(get_current_or_enrolling_user),
) -> SessionUser:
if body.method == "totp":
if not body.secret or not verify_totp_code(body.secret, body.code.strip()):
raise app_error(401, "INVALID_2FA_CODE", "Feil kode.")
async with plain_connection() as conn:
await conn.execute(
"UPDATE app_user SET two_factor_method = 'totp', totp_secret = $1 WHERE id = $2",
body.secret,
user.user_id,
)
else:
async with plain_connection() as conn:
row = await conn.fetchrow(
"""
UPDATE two_factor_code SET consumed_at = now()
WHERE user_id = $1 AND code_hash = $2 AND consumed_at IS NULL AND expires_at > now()
RETURNING id
""",
user.user_id,
_hash_secret(body.code.strip()),
)
if row is None:
raise app_error(401, "INVALID_2FA_CODE", "Feil eller utløpt kode.")
await conn.execute(
"UPDATE app_user SET two_factor_method = 'email' WHERE id = $1", user.user_id
)
# Oppsettet er fullført -- utsted en FULL sesjon direkte, IKKE via
# _issue_login_result() sin vanlige gjenbrukte 2FA-sjekk: den ville nå
# (feilaktig) sett two_factor_method nettopp satt og krevd EN NY runde
# med 2FA-verifisering for akkurat denne innloggingen, selv om brukeren
# bokstavelig talt akkurat beviste koden i confirm-kallet over. Fant
# under scratch-testing (AssertionError -- resultatet ble "2fa_required"
# i stedet for "success").
async with plain_connection() as conn:
user_row = await conn.fetchrow(
"""
SELECT id::text AS id, email::text AS email, display_name, preferred_locale
FROM app_user WHERE id = $1
""",
user.user_id,
)
token = create_session_token(user.user_id, stage="full")
_set_session_cookie(response, request, token, "full")
return SessionUser(**dict(user_row)) return SessionUser(**dict(user_row))
@router.post("/2fa/disable")
async def disable_two_factor(user: CurrentUser = Depends(get_current_user)) -> dict:
async with plain_connection() as conn:
await conn.execute(
"UPDATE app_user SET two_factor_method = NULL, totp_secret = NULL WHERE id = $1",
user.user_id,
)
return {"status": "ok"}
@router.post("/logout") @router.post("/logout")
async def logout(response: Response) -> dict: async def logout(response: Response) -> dict:
response.delete_cookie(SESSION_COOKIE_NAME, path="/") response.delete_cookie(SESSION_COOKIE_NAME, path="/")
@ -206,6 +560,8 @@ class Me(BaseModel):
email: str email: str
display_name: str display_name: str
preferred_locale: str preferred_locale: str
has_password: bool
two_factor_method: Literal["totp", "email"] | None
organizations: list[MyOrg] organizations: list[MyOrg]
@ -224,7 +580,8 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
async with plain_connection() as conn: async with plain_connection() as conn:
user_row = await conn.fetchrow( user_row = await conn.fetchrow(
""" """
SELECT id::text AS id, email::text AS email, display_name, preferred_locale SELECT id::text AS id, email::text AS email, display_name, preferred_locale,
(password_hash IS NOT NULL) AS has_password, two_factor_method
FROM app_user WHERE id = $1 FROM app_user WHERE id = $1
""", """,
user.user_id, user.user_id,
@ -247,5 +604,7 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me:
email=user_row["email"], email=user_row["email"],
display_name=user_row["display_name"], display_name=user_row["display_name"],
preferred_locale=user_row["preferred_locale"], preferred_locale=user_row["preferred_locale"],
has_password=user_row["has_password"],
two_factor_method=user_row["two_factor_method"],
organizations=organizations, organizations=organizations,
) )

View file

@ -17,13 +17,16 @@ Verifisert med en negativ kontroll (mismatchende id blir avvist) i
scratch-testingen -- se plan. scratch-testingen -- se plan.
""" """
import traceback
import uuid import uuid
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field from pydantic import BaseModel, EmailStr, Field
from ..auth import CurrentUser, get_authorized_org, get_current_user from ..auth import CurrentUser, get_authorized_org, get_current_user, get_superadmin_user
from ..config import settings
from ..db import org_connection from ..db import org_connection
from ..email import send_organization_invitation_email
from ..errors import app_error, translate_db_errors from ..errors import app_error, translate_db_errors
router = APIRouter() router = APIRouter()
@ -105,3 +108,319 @@ async def update_organization(
if row is None: if row is None:
raise app_error(404, "NOT_FOUND", "Organisasjonen finnes ikke.") raise app_error(404, "NOT_FOUND", "Organisasjonen finnes ikke.")
return OrganizationSettings(**dict(row)) return OrganizationSettings(**dict(row))
# ---------------------------------------------------------------------------
# Medlemskap (ADR-022) -- rollestyring, frasi seg eierskap, forlate org.
# ---------------------------------------------------------------------------
async def _caller_role(conn, organization_id: str, user_id: str) -> str | None:
return await conn.fetchval(
"SELECT role FROM organization_membership WHERE organization_id = $1 AND user_id = $2",
organization_id,
user_id,
)
async def _owner_count_locked(conn, organization_id: str) -> int:
"""Låser alle eier-rader for organisasjonen for resten av transaksjonen --
TOCTOU-trygt mot at to samtidige nedgraderinger begge tror det finnes
minst to eiere. Samme mønster som ADR-011s to-lags-grense."""
rows = await conn.fetch(
"SELECT id FROM organization_membership WHERE organization_id = $1 AND role = 'owner' FOR UPDATE",
organization_id,
)
return len(rows)
_ROLE_RANK = {"member": 0, "admin": 1, "owner": 2}
class MembershipOut(BaseModel):
id: str
user_id: str
email: str
display_name: str
role: str
@router.get("/orgs/{organization_id}/memberships", response_model=list[MembershipOut])
async def list_memberships(organization_id: str = Depends(get_authorized_org)) -> list[MembershipOut]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"""
SELECT om.id::text AS id, om.user_id::text AS user_id, om.role,
u.email::text AS email, u.display_name
FROM organization_membership om
JOIN app_user u ON u.id = om.user_id
WHERE om.organization_id = $1
ORDER BY u.display_name
""",
organization_id,
)
return [MembershipOut(**dict(r)) for r in rows]
class MembershipUpdate(BaseModel):
role: str = Field(pattern="^(owner|admin|member)$")
@router.patch("/orgs/{organization_id}/memberships/{membership_id}", response_model=MembershipOut)
async def update_membership(
membership_id: str,
body: MembershipUpdate,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> MembershipOut:
"""Eier kan endre ENHVER rolle. En bruker kan alltid SENKE sin egen rolle
selv (frasi seg eierskap er selvbetjent, ADR-022 Beslutning C) -- men
ALDRI heve den selv, det ville vært en privilegie-eskaleringsvei.
Avviser med 409 LAST_OWNER hvis endringen ville latt organisasjonen stå
igjen uten noen eier."""
async with org_connection(organization_id) as conn, translate_db_errors():
target = await conn.fetchrow(
"""
SELECT id::text AS id, user_id::text AS user_id, role
FROM organization_membership WHERE id = $1 AND organization_id = $2
FOR UPDATE
""",
membership_id,
organization_id,
)
if target is None:
raise app_error(404, "NOT_FOUND", "Medlemskapet finnes ikke.")
is_self = target["user_id"] == user.user_id
if is_self:
if _ROLE_RANK[body.role] >= _ROLE_RANK[target["role"]]:
raise app_error(
403, "NOT_ORG_OWNER", "Du kan bare senke din egen rolle selv, ikke heve den."
)
else:
caller_role = await _caller_role(conn, organization_id, user.user_id)
if caller_role != "owner":
raise app_error(403, "NOT_ORG_OWNER", "Kun organisasjonseiere kan endre andres rolle.")
if target["role"] == "owner" and body.role != "owner":
if await _owner_count_locked(conn, organization_id) <= 1:
raise app_error(409, "LAST_OWNER", "Organisasjonen må ha minst én eier.")
row = await conn.fetchrow(
"""
WITH updated AS (
UPDATE organization_membership SET role = $1 WHERE id = $2
RETURNING id, user_id, role
)
SELECT updated.id::text AS id, updated.user_id::text AS user_id, updated.role,
u.email::text AS email, u.display_name
FROM updated JOIN app_user u ON u.id = updated.user_id
""",
body.role,
membership_id,
)
return MembershipOut(**dict(row))
@router.delete("/orgs/{organization_id}/memberships/{membership_id}", status_code=204)
async def delete_membership(
membership_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> None:
"""Eier kan fjerne ethvert medlem. Enhver bruker kan fjerne SEG SELV
(forlate organisasjonen). Avviser med 409 LAST_OWNER hvis den siste
eieren prøver å fjernes/forlate uten å ha overført eierskap først."""
async with org_connection(organization_id) as conn:
target = await conn.fetchrow(
"""
SELECT user_id::text AS user_id, role
FROM organization_membership WHERE id = $1 AND organization_id = $2
FOR UPDATE
""",
membership_id,
organization_id,
)
if target is None:
raise app_error(404, "NOT_FOUND", "Medlemskapet finnes ikke.")
is_self = target["user_id"] == user.user_id
if not is_self:
caller_role = await _caller_role(conn, organization_id, user.user_id)
if caller_role != "owner":
raise app_error(403, "NOT_ORG_OWNER", "Kun organisasjonseiere kan fjerne andre medlemmer.")
if target["role"] == "owner":
if await _owner_count_locked(conn, organization_id) <= 1:
raise app_error(
409,
"LAST_OWNER",
"Organisasjonen må ha minst én eier -- overfør eierskap før du forlater.",
)
await conn.execute("DELETE FROM organization_membership WHERE id = $1", membership_id)
# ---------------------------------------------------------------------------
# Invitasjoner (ADR-022 Beslutning B) -- e-post-basert, ingen egen
# klikkbar aksept-lenke. Godtas automatisk ved neste innlogging med
# matchende e-post (app/routers/auth.py sin accept_pending_invitations_by_email).
# ---------------------------------------------------------------------------
class InvitationCreate(BaseModel):
email: EmailStr
role: str = Field(pattern="^(owner|admin|member)$")
class InvitationOut(BaseModel):
id: str
email: str
role: str
invited_by: str
created_at: str
expires_at: str
@router.post(
"/orgs/{organization_id}/invitations", response_model=InvitationOut, status_code=201
)
async def create_invitation(
body: InvitationCreate,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> InvitationOut:
async with org_connection(organization_id) as conn, translate_db_errors():
caller_role = await _caller_role(conn, organization_id, user.user_id)
if caller_role not in ("owner", "admin"):
raise app_error(403, "NOT_ORG_OWNER", "Kun eiere og administratorer kan invitere.")
# Minste-privilegium (ADR-022 Beslutning B): en admin kan gi noen
# tilgang som medlem, men ALDRI gi noen eier-/adminrettigheter --
# det ville vært en privilegie-eskaleringsvei.
if caller_role == "admin" and body.role != "member":
raise app_error(
403, "NOT_ORG_OWNER", "Administratorer kan kun invitere som medlem, ikke som eier/admin."
)
org_name = await conn.fetchval("SELECT name FROM organization WHERE id = $1", organization_id)
inviter = await conn.fetchrow(
"SELECT display_name, preferred_locale FROM app_user WHERE id = $1", user.user_id
)
row = await conn.fetchrow(
"""
INSERT INTO organization_invitation
(organization_id, email, role, invited_by, expires_at)
VALUES ($1, $2, $3, $4, now() + interval '14 days')
RETURNING id::text, email::text, role, invited_by::text AS invited_by,
created_at::text, expires_at::text
""",
organization_id,
body.email.lower(),
body.role,
user.user_id,
)
if settings.SMTP_CONFIGURED:
try:
await send_organization_invitation_email(
body.email.lower(), org_name, body.role, inviter["display_name"], inviter["preferred_locale"]
)
except Exception:
# Se app/email.py sitt mønster -- driftsfeil i utsendingen skal
# aldri hindre selve invitasjonen fra å bli opprettet.
traceback.print_exc()
elif settings.DEV_LOG_MAGIC_LINKS:
print(
f"[DEV] Invitasjon til {body.email.lower()} for {org_name} som {body.role}", flush=True
)
return InvitationOut(**dict(row))
@router.get("/orgs/{organization_id}/invitations", response_model=list[InvitationOut])
async def list_invitations(organization_id: str = Depends(get_authorized_org)) -> list[InvitationOut]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"""
SELECT id::text, email::text, role, invited_by::text AS invited_by,
created_at::text, expires_at::text
FROM organization_invitation
WHERE organization_id = $1 AND consumed_at IS NULL AND expires_at > now()
ORDER BY created_at DESC
""",
organization_id,
)
return [InvitationOut(**dict(r)) for r in rows]
@router.delete("/orgs/{organization_id}/invitations/{invitation_id}", status_code=204)
async def revoke_invitation(
invitation_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> None:
async with org_connection(organization_id) as conn:
invitation = await conn.fetchrow(
"SELECT role FROM organization_invitation WHERE id = $1 AND organization_id = $2",
invitation_id,
organization_id,
)
if invitation is None:
raise app_error(404, "NOT_FOUND", "Invitasjonen finnes ikke.")
caller_role = await _caller_role(conn, organization_id, user.user_id)
allowed = caller_role == "owner" or (caller_role == "admin" and invitation["role"] == "member")
if not allowed:
raise app_error(403, "NOT_ORG_OWNER", "Du har ikke rettigheter til å oppheve denne invitasjonen.")
await conn.execute("DELETE FROM organization_invitation WHERE id = $1", invitation_id)
# ---------------------------------------------------------------------------
# Superadmin (ADR-022 Beslutning D) -- kun for kontoer med is_super_admin
# satt DIREKTE i databasen (aldri via noe API). Kan sette medlemskap på
# ENHVER organisasjon, uavhengig av eget medlemskap -- bevisst avgrenset
# til nøyaktig dette (ikke generell tilgang til andres turnering-/spillerdata).
# ---------------------------------------------------------------------------
class SuperadminMembershipSet(BaseModel):
email: EmailStr
role: str = Field(pattern="^(owner|admin|member)$")
@router.post("/superadmin/orgs/{organization_id}/memberships", response_model=MembershipOut)
async def superadmin_set_membership(
organization_id: str,
body: SuperadminMembershipSet,
_admin: CurrentUser = Depends(get_superadmin_user),
) -> MembershipOut:
async with org_connection(organization_id) as conn, translate_db_errors():
target_user = await conn.fetchrow(
"SELECT id::text AS id, display_name FROM app_user WHERE email = $1", body.email.lower()
)
if target_user is None:
raise app_error(
404,
"NOT_FOUND",
"Fant ingen bruker med denne e-posten -- personen må ha logget inn minst én gang først.",
)
row = await conn.fetchrow(
"""
INSERT INTO organization_membership (organization_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (organization_id, user_id) DO UPDATE SET role = EXCLUDED.role
RETURNING id::text, user_id::text, role
""",
organization_id,
target_user["id"],
body.role,
)
return MembershipOut(
id=row["id"],
user_id=row["user_id"],
role=row["role"],
email=body.email.lower(),
display_name=target_user["display_name"],
)

View file

@ -0,0 +1,5 @@
import { AccountSettings } from "@/components/account-settings"
export default function AccountPage() {
return <AccountSettings />
}

View file

@ -0,0 +1,13 @@
import { OrgMembers } from "@/components/org-members"
export default async function OrgMembersPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ name?: string }>
}) {
const { id } = await params
const { name } = await searchParams
return <OrgMembers organizationId={id} orgName={name ?? "Organisasjon"} />
}

View file

@ -0,0 +1,238 @@
"use client"
// Kontoinnstillinger (ADR-021): sett/fjern passord (valgfritt tillegg til
// magic-link), aktiver/deaktiver 2FA. Frivillig 2FA-oppsett herfra bruker
// SAMME TwoFactorSetupForm som den tvungne flyten rett etter innlogging
// (forced=false her -- kun tekst-copyen skiller de to).
import type React from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, KeyRound, Lock, ShieldCheck, ShieldOff } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
type Me = {
id: string
email: string
display_name: string
has_password: boolean
two_factor_method: "totp" | "email" | null
}
export function AccountSettings() {
const [me, setMe] = useState<Me | null>(null)
const [loading, setLoading] = useState(true)
const [settingUp2fa, setSettingUp2fa] = useState(false)
async function loadMe() {
try {
const res = await fetch("/auth/me", { credentials: "include" })
if (res.ok) setMe(await res.json())
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadMe()
}, [])
async function handleDisable2fa() {
if (!confirm("Er du sikker på at du vil slå av topartsautentisering?")) return
const res = await fetch("/auth/2fa/disable", { method: "POST", credentials: "include" })
if (res.ok) void loadMe()
}
if (loading) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
if (!me) return null
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
<Link
href="/dashboard"
aria-label="Tilbake til dashbord"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Konto</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{me.email}</h1>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
{settingUp2fa ? (
<div className="rounded-3xl border border-border bg-card p-6 shadow-sm shadow-black/5 sm:p-8">
<TwoFactorSetupForm
forced={false}
onSuccess={() => {
setSettingUp2fa(false)
void loadMe()
}}
/>
</div>
) : (
<div className="flex flex-col gap-6">
<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">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/15">
<ShieldCheck aria-hidden="true" className="size-5 text-primary" />
</div>
<h2 className="text-base font-bold text-foreground">Topartsautentisering (2FA)</h2>
</div>
{me.two_factor_method ? (
<>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Aktivert med{" "}
<span className="font-semibold text-foreground">
{me.two_factor_method === "totp" ? "autentisator-app" : "engangskode på e-post"}
</span>
.
</p>
<Button
type="button"
variant="outline"
onClick={handleDisable2fa}
className="h-12 w-fit rounded-2xl font-semibold text-destructive hover:text-destructive"
>
<ShieldOff aria-hidden="true" className="size-4" />
Slå av 2FA
</Button>
</>
) : (
<>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ikke aktivert. Anbefales for organisasjonseiere/administratorer -- da vil du
bli bedt om å sette det opp ved neste innlogging uansett.
</p>
<Button
type="button"
onClick={() => setSettingUp2fa(true)}
className="h-12 w-fit rounded-2xl font-semibold"
>
<KeyRound aria-hidden="true" className="size-4" />
Sett opp 2FA
</Button>
</>
)}
</section>
</div>
)}
</main>
</div>
)
}
function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) {
const [password, setPassword] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (password.length < 8 || submitting) return
setSubmitting(true)
setError(null)
setSuccess(false)
try {
const res = await fetch("/auth/set-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ password }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å sette passordet.")
}
setPassword("")
setSuccess(true)
onChanged()
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSubmitting(false)
}
}
async function handleRemove() {
if (!confirm("Fjerne passordet? Du kan fortsatt logge inn med magic-link.")) return
const res = await fetch("/auth/remove-password", { method: "POST", credentials: "include" })
if (res.ok) onChanged()
}
return (
<section className="flex flex-col gap-4 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">
<Lock aria-hidden="true" className="size-5 text-primary" />
</div>
<h2 className="text-base font-bold text-foreground">Passord</h2>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{hasPassword
? "Du har et passord satt. Du kan fortsatt logge inn med magic-link når som helst."
: "Ikke satt -- du logger i dag kun inn med magic-link. Passord er et valgfritt tillegg, aldri påkrevd."}
</p>
<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="new-password" className="text-sm font-semibold">
{hasPassword ? "Nytt passord" : "Sett et passord"}
</Label>
<Input
id="new-password"
type="password"
autoComplete="new-password"
placeholder="Minst 8 tegn"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-12 rounded-xl"
/>
</div>
<Button
type="submit"
disabled={password.length < 8 || submitting}
className="h-12 shrink-0 rounded-xl font-semibold"
>
{submitting ? "Lagrer …" : hasPassword ? "Bytt passord" : "Sett passord"}
</Button>
</form>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{success && <p className="text-sm font-medium text-primary">Passordet er oppdatert.</p>}
{hasPassword && (
<button
type="button"
onClick={handleRemove}
className="w-fit text-sm font-semibold text-muted-foreground transition-colors hover:text-destructive"
>
Fjern passordet
</button>
)}
</section>
)
}

View file

@ -3,7 +3,8 @@
import type React from "react" import type React from "react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Building2, ChevronsUpDown, LogOut, Plus, Trophy, X, Check } from "lucide-react" import Link from "next/link"
import { Building2, ChevronsUpDown, LogOut, Plus, Trophy, Users, UserCircle, X, Check } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@ -185,6 +186,14 @@ export function Dashboard() {
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur"> <header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4"> <div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
<Wordmark compact /> <Wordmark compact />
<div className="flex items-center gap-1">
<Link
href="/account"
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<UserCircle aria-hidden="true" className="size-4" />
Konto
</Link>
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}
@ -194,6 +203,7 @@ export function Dashboard() {
Logg ut Logg ut
</button> </button>
</div> </div>
</div>
</header> </header>
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10"> <main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
@ -316,8 +326,17 @@ function OrganizationView({
</div> </div>
)} )}
<div className="flex items-center gap-2">
<Link
href={`/orgs/${org.organization_id}/members?name=${encodeURIComponent(org.name)}`}
className="inline-flex h-11 items-center gap-1.5 rounded-2xl border border-border bg-card px-4 text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
>
<Users aria-hidden="true" className="size-4" />
Medlemmer
</Link>
<NewTournamentControl onCreate={onCreateTournament} /> <NewTournamentControl onCreate={onCreateTournament} />
</div> </div>
</div>
<section className="flex flex-col gap-3" aria-label="Turneringer"> <section className="flex flex-col gap-3" aria-label="Turneringer">
{loadingTournaments ? ( {loadingTournaments ? (

View file

@ -3,19 +3,41 @@
import type React from "react" import type React from "react"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Mail, ArrowLeft, CheckCircle2, KeyRound } from "lucide-react" import { Mail, ArrowLeft, CheckCircle2, KeyRound, Lock } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { TwoFactorSetupForm, TwoFactorVerifyForm } from "@/components/two-factor-flow"
const RESEND_COOLDOWN = 30 // seconds const RESEND_COOLDOWN = 30 // seconds
type SessionUser = {
id: string
email: string
display_name: string
preferred_locale: string
}
type LoginResult = {
status: "success" | "2fa_required" | "2fa_setup_required"
user?: SessionUser
two_factor_method?: "totp" | "email"
}
function isValidEmail(value: string) { function isValidEmail(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
} }
// ADR-021 Beslutning E: primær-autentisering (magic-link ELLER passord) kan
// returnere tre ulike utfall -- delt mellom denne komponenten og
// verify-form.tsx sin håndtering av magic-link-svaret.
type PostAuthMode = "2fa-verify" | "2fa-setup" | null
export function LoginForm() { export function LoginForm() {
const [mode, setMode] = useState<"email" | "code">("email") const router = useRouter()
const [mode, setMode] = useState<"email" | "code" | "password">("email")
const [postAuth, setPostAuth] = useState<PostAuthMode>(null)
const [twoFactorMethod, setTwoFactorMethod] = useState<"totp" | "email" | null>(null)
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [touched, setTouched] = useState(false) const [touched, setTouched] = useState(false)
const [sent, setSent] = useState(false) const [sent, setSent] = useState(false)
@ -23,6 +45,21 @@ export function LoginForm() {
const [cooldown, setCooldown] = useState(0) const [cooldown, setCooldown] = useState(0)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
function handleLoginResult(result: LoginResult) {
if (result.status === "success") {
router.replace("/dashboard")
} else if (result.status === "2fa_required") {
setTwoFactorMethod(result.two_factor_method ?? "totp")
setPostAuth("2fa-verify")
} else {
setPostAuth("2fa-setup")
}
}
function handleTwoFactorSuccess() {
router.replace("/dashboard")
}
const emailValid = isValidEmail(email) const emailValid = isValidEmail(email)
const showError = touched && email.length > 0 && !emailValid const showError = touched && email.length > 0 && !emailValid
@ -76,7 +113,15 @@ export function LoginForm() {
return ( return (
<div className="w-full rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8"> <div className="w-full rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
{sent ? ( {postAuth === "2fa-verify" ? (
<TwoFactorVerifyForm
method={twoFactorMethod ?? "totp"}
onSuccess={handleTwoFactorSuccess}
onBack={() => setPostAuth(null)}
/>
) : postAuth === "2fa-setup" ? (
<TwoFactorSetupForm forced onSuccess={handleTwoFactorSuccess} />
) : sent ? (
<ConfirmationState <ConfirmationState
email={email} email={email}
cooldown={cooldown} cooldown={cooldown}
@ -87,6 +132,8 @@ export function LoginForm() {
/> />
) : mode === "code" ? ( ) : mode === "code" ? (
<JoinByCode onBack={() => setMode("email")} /> <JoinByCode onBack={() => setMode("email")} />
) : mode === "password" ? (
<PasswordLoginForm onResult={handleLoginResult} onBack={() => setMode("email")} />
) : ( ) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate> <form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@ -135,9 +182,18 @@ export function LoginForm() {
</Button> </Button>
<p className="text-center text-sm leading-relaxed text-muted-foreground text-pretty"> <p className="text-center text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen passord. Vi sender deg en sikker lenke e-post. Ingen passord nødvendig. Vi sender deg en sikker lenke e-post.
</p> </p>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => setMode("password")}
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<Lock aria-hidden="true" className="size-4" />
Logg inn med e-post og passord i stedet
</button>
<button <button
type="button" type="button"
onClick={() => setMode("code")} onClick={() => setMode("code")}
@ -146,12 +202,125 @@ export function LoginForm() {
<KeyRound aria-hidden="true" className="size-4" /> <KeyRound aria-hidden="true" className="size-4" />
Har du en invitasjonskode? rett til turneringen Har du en invitasjonskode? rett til turneringen
</button> </button>
</div>
</form> </form>
)} )}
</div> </div>
) )
} }
// --- Passord-innlogging (ADR-021 Beslutning A) -- et sidestilt, valgfritt
// alternativ til magic-link, ALDRI en erstatning. -----------------------
function PasswordLoginForm({
onResult,
onBack,
}: {
onResult: (result: LoginResult) => void
onBack: () => void
}) {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!email.trim() || !password || submitting) return
setSubmitting(true)
setError(null)
try {
const res = await fetch("/auth/login-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email: email.trim(), password }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "E-post eller passord er feil.")
}
onResult(await res.json())
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col gap-2">
<Label htmlFor="pw-email" className="text-sm font-semibold">
E-post
</Label>
<div className="relative">
<Mail
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
<Input
id="pw-email"
type="email"
inputMode="email"
autoComplete="email"
autoFocus
placeholder="deg@epost.no"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-14 rounded-2xl pl-11 text-base"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="pw-password" className="text-sm font-semibold">
Passord
</Label>
<div className="relative">
<Lock
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
{/* Bevisst ingen begrensning på tegn -- spesialtegn/mellomrom skal fungere. */}
<Input
id="pw-password"
type="password"
autoComplete="current-password"
placeholder="Passordet ditt"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-14 rounded-2xl pl-11 text-base"
/>
</div>
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
{error}
</p>
)}
<Button
type="submit"
disabled={submitting || !email.trim() || !password}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
{submitting ? "Logger inn …" : "Logg inn"}
</Button>
<button
type="button"
onClick={onBack}
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-4" />
Bruk innloggingslenke e-post i stedet
</button>
</form>
)
}
// --- Kode-innlogging (ADR-020): tar deg rett til en turnering via en kort, // --- Kode-innlogging (ADR-020): tar deg rett til en turnering via en kort,
// menneske-skrivbar kode -- FØR innlogging, og uansett turneringens // menneske-skrivbar kode -- FØR innlogging, og uansett turneringens
// synlighet (koden ER selve invitasjonen, se ADR-020 Beslutning A). --------- // synlighet (koden ER selve invitasjonen, se ADR-020 Beslutning A). ---------

View file

@ -0,0 +1,366 @@
"use client"
// Medlemsstyring (ADR-022): invitere, endre rolle, fjerne/forlate. Rolle-
// grensene håndheves også server-side (app/routers/organizations.py) --
// UI-et her skjuler bare handlinger som uansett ville blitt avvist, for en
// tydeligere opplevelse, ikke som eneste håndhevelse.
import type React from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Mail, Trash2, UserPlus, Users } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
type Role = "owner" | "admin" | "member"
type Membership = {
id: string
user_id: string
email: string
display_name: string
role: Role
}
type Invitation = {
id: string
email: string
role: Role
expires_at: string
}
const ROLE_LABELS: Record<Role, string> = { owner: "Eier", admin: "Administrator", member: "Medlem" }
export function OrgMembers({ organizationId, orgName }: { organizationId: string; orgName: string }) {
const [myUserId, setMyUserId] = useState<string | null>(null)
const [myRole, setMyRole] = useState<Role | null>(null)
const [members, setMembers] = useState<Membership[]>([])
const [invitations, setInvitations] = useState<Invitation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
async function loadAll() {
try {
const [meRes, membersRes, invitationsRes] = await Promise.all([
fetch("/auth/me", { credentials: "include" }),
fetch(`/orgs/${organizationId}/memberships`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/invitations`, { credentials: "include" }),
])
if (meRes.ok) {
const me: { id: string; organizations: { organization_id: string; role: Role }[] } = await meRes.json()
setMyUserId(me.id)
setMyRole(me.organizations.find((o) => o.organization_id === organizationId)?.role ?? null)
}
if (membersRes.ok) setMembers(await membersRes.json())
if (invitationsRes.ok) setInvitations(await invitationsRes.json())
} catch {
setError("Klarte ikke å laste medlemmer. Prøv å laste siden på nytt.")
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organizationId])
async function inviteMember(email: string, role: Role) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/invitations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, role }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å sende invitasjonen.")
}
const created: Invitation = await res.json()
setInvitations((prev) => [created, ...prev])
} catch (err) {
setError(err instanceof Error ? err.message : "Klarte ikke å sende invitasjonen.")
}
}
async function revokeInvitation(id: string) {
const res = await fetch(`/orgs/${organizationId}/invitations/${id}`, {
method: "DELETE",
credentials: "include",
})
if (res.ok || res.status === 204) {
setInvitations((prev) => prev.filter((i) => i.id !== id))
}
}
async function changeRole(membershipId: string, role: Role) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/memberships/${membershipId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ role }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å endre rollen.")
}
const updated: Membership = await res.json()
setMembers((prev) => prev.map((m) => (m.id === updated.id ? updated : m)))
} catch (err) {
setError(err instanceof Error ? err.message : "Klarte ikke å endre rollen.")
}
}
async function removeMember(membershipId: string, isSelf: boolean) {
const confirmMsg = isSelf
? "Er du sikker på at du vil forlate organisasjonen?"
: "Er du sikker på at du vil fjerne dette medlemmet?"
if (!confirm(confirmMsg)) return
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/memberships/${membershipId}`, {
method: "DELETE",
credentials: "include",
})
if (!res.ok && res.status !== 204) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å fjerne medlemmet.")
}
setMembers((prev) => prev.filter((m) => m.id !== membershipId))
if (isSelf) window.location.href = "/dashboard"
} catch (err) {
setError(err instanceof Error ? err.message : "Klarte ikke å fjerne medlemmet.")
}
}
const canInvite = myRole === "owner" || myRole === "admin"
const inviteRoleOptions: Role[] = myRole === "owner" ? ["member", "admin", "owner"] : ["member"]
if (loading) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
<Link
href="/dashboard"
aria-label="Tilbake til dashbord"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Medlemmer
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{orgName}</h1>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
{error && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{error}
</p>
)}
{canInvite && <InviteForm roleOptions={inviteRoleOptions} onInvite={inviteMember} />}
<section className="mt-6 flex flex-col gap-3">
<h2 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-muted-foreground">
<Users aria-hidden="true" className="size-4" />
Medlemmer ({members.length})
</h2>
{members.map((m) => (
<MemberRow
key={m.id}
member={m}
isSelf={m.user_id === myUserId}
canManage={myRole === "owner"}
onChangeRole={changeRole}
onRemove={removeMember}
/>
))}
</section>
{invitations.length > 0 && (
<section className="mt-6 flex flex-col gap-3">
<h2 className="flex items-center gap-2 text-sm font-bold uppercase tracking-wide text-muted-foreground">
<Mail aria-hidden="true" className="size-4" />
Ventende invitasjoner ({invitations.length})
</h2>
{invitations.map((inv) => (
<div
key={inv.id}
className="flex items-center justify-between gap-3 rounded-2xl border border-dashed border-border bg-card/50 p-4"
>
<div className="flex flex-col">
<span className="text-sm font-semibold text-foreground">{inv.email}</span>
<span className="text-xs text-muted-foreground">Invitert som {ROLE_LABELS[inv.role]}</span>
</div>
{(myRole === "owner" || (myRole === "admin" && inv.role === "member")) && (
<button
type="button"
onClick={() => void revokeInvitation(inv.id)}
aria-label={`Opphev invitasjon til ${inv.email}`}
className="flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
)}
</div>
))}
</section>
)}
</main>
</div>
)
}
function InviteForm({
roleOptions,
onInvite,
}: {
roleOptions: Role[]
onInvite: (email: string, role: Role) => void
}) {
const [email, setEmail] = useState("")
const [role, setRole] = useState<Role>(roleOptions[0])
const [sending, setSending] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!email.trim() || sending) return
setSending(true)
try {
await onInvite(email.trim(), role)
setEmail("")
} finally {
setSending(false)
}
}
return (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:flex-row sm:items-end sm:p-5"
>
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="invite-email" className="text-sm font-semibold">
Inviter noen
</Label>
<Input
id="invite-email"
type="email"
placeholder="epost@eksempel.no"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="invite-role" className="text-sm font-semibold">
Rolle
</Label>
<select
id="invite-role"
value={role}
onChange={(e) => setRole(e.target.value as Role)}
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{roleOptions.map((r) => (
<option key={r} value={r}>
{ROLE_LABELS[r]}
</option>
))}
</select>
</div>
<Button type="submit" disabled={!email.trim() || sending} className="h-11 shrink-0 rounded-xl font-semibold">
<UserPlus aria-hidden="true" className="size-4" />
{sending ? "Sender …" : "Inviter"}
</Button>
</form>
)
}
function MemberRow({
member,
isSelf,
canManage,
onChangeRole,
onRemove,
}: {
member: Membership
isSelf: boolean
canManage: boolean
onChangeRole: (id: string, role: Role) => void
onRemove: (id: string, isSelf: boolean) => void
}) {
// Selvbetjent nedgradering (aldri oppgradering, se app/routers/organizations.py)
// -- viser kun rollene som faktisk er lavere enn nåværende for egen rad når
// brukeren ikke er owner (owner kan endre fritt).
const rank: Record<Role, number> = { member: 0, admin: 1, owner: 2 }
const roleOptionsForRow: Role[] = canManage
? ["owner", "admin", "member"]
: isSelf
? (["owner", "admin", "member"] as Role[]).filter((r) => rank[r] <= rank[member.role])
: []
const canEditRole = canManage || (isSelf && roleOptionsForRow.length > 1)
const canRemove = canManage || isSelf
return (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-semibold text-foreground">
{member.display_name}
{isSelf && <span className="ml-1.5 text-xs font-medium text-muted-foreground">(deg)</span>}
</span>
<span className="truncate text-xs text-muted-foreground">{member.email}</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{canEditRole ? (
<select
value={member.role}
onChange={(e) => onChangeRole(member.id, e.target.value as Role)}
className="h-9 rounded-lg border border-border bg-card px-2 text-xs font-semibold text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{roleOptionsForRow.map((r) => (
<option key={r} value={r}>
{ROLE_LABELS[r]}
</option>
))}
</select>
) : (
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
{ROLE_LABELS[member.role]}
</span>
)}
{canRemove && (
<button
type="button"
onClick={() => onRemove(member.id, isSelf)}
aria-label={isSelf ? "Forlat organisasjonen" : `Fjern ${member.display_name}`}
className="flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,370 @@
"use client"
// Delt mellom login-form.tsx (passord-modus) og verify-form.tsx (magic-link)
// -- begge kan returnere status "2fa_required"/"2fa_setup_required" fra
// backend (ADR-021 Beslutning E), og trenger identisk oppfølging.
import type React from "react"
import { useEffect, useState } from "react"
import { ArrowLeft, KeyRound, Mail, ShieldCheck } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
type SessionUser = {
id: string
email: string
display_name: string
preferred_locale: string
}
type LoginResult = {
status: "success" | "2fa_required" | "2fa_setup_required"
user?: SessionUser
two_factor_method?: "totp" | "email"
}
// --- Verifisering av en PÅGÅENDE innlogging (status "2fa_required") --------
export function TwoFactorVerifyForm({
method,
onSuccess,
onBack,
}: {
method: "totp" | "email"
onSuccess: (user: SessionUser) => void
onBack: () => void
}) {
const [code, setCode] = useState("")
const [submitting, setSubmitting] = useState(false)
const [sendingEmail, setSendingEmail] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const [error, setError] = useState<string | null>(null)
async function requestEmailCode() {
setSendingEmail(true)
setError(null)
try {
const res = await fetch("/auth/2fa/email/request", { method: "POST", credentials: "include" })
if (!res.ok) throw new Error("request failed")
setEmailSent(true)
} catch {
setError("Klarte ikke å sende koden. Prøv igjen.")
} finally {
setSendingEmail(false)
}
}
useEffect(() => {
if (method === "email") void requestEmailCode()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [method])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!code.trim() || submitting) return
setSubmitting(true)
setError(null)
try {
const res = await fetch("/auth/2fa/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ code: code.trim() }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Feil eller utløpt kode.")
}
const result: LoginResult = await res.json()
if (result.status === "success" && result.user) {
onSuccess(result.user)
} else {
throw new Error("Uventet svar fra serveren.")
}
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
<ShieldCheck aria-hidden="true" className="size-7 text-primary" />
</div>
<div className="flex flex-col gap-1">
<h2 className="text-xl font-extrabold tracking-tight">Topartsautentisering</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{method === "totp"
? "Skriv inn koden fra autentisator-appen din."
: emailSent
? "Vi har sendt en engangskode til e-posten din."
: "Sender en engangskode til e-posten din …"}
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="tfa-code" className="text-sm font-semibold">
Kode
</Label>
<div className="relative">
{method === "totp" ? (
<KeyRound
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
) : (
<Mail
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
/>
)}
<Input
id="tfa-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value)}
className="h-14 rounded-2xl pl-11 text-base tracking-widest"
/>
</div>
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
{error}
</p>
)}
<Button
type="submit"
disabled={submitting || !code.trim()}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
{submitting ? "Bekrefter …" : "Bekreft"}
</Button>
{method === "email" && (
<button
type="button"
onClick={requestEmailCode}
disabled={sendingEmail}
className="text-center text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
{sendingEmail ? "Sender …" : "Send koden på nytt"}
</button>
)}
<button
type="button"
onClick={onBack}
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-4" />
Tilbake til innlogging
</button>
</form>
)
}
// --- Oppsett av 2FA (status "2fa_setup_required" ELLER frivillig fra
// kontoinnstillinger) -------------------------------------------------
type SetupStartResult = {
status: "totp_ready" | "email_sent"
secret?: string
otpauth_uri?: string
qr_code_data_uri?: string
}
export function TwoFactorSetupForm({
forced,
onSuccess,
}: {
forced: boolean
onSuccess: (user: SessionUser) => void
}) {
const [method, setMethod] = useState<"totp" | "email" | null>(null)
const [starting, setStarting] = useState(false)
const [setupInfo, setSetupInfo] = useState<SetupStartResult | null>(null)
const [code, setCode] = useState("")
const [confirming, setConfirming] = useState(false)
const [error, setError] = useState<string | null>(null)
async function chooseMethod(chosen: "totp" | "email") {
setMethod(chosen)
setStarting(true)
setError(null)
try {
const res = await fetch("/auth/2fa/setup/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ method: chosen }),
})
if (!res.ok) throw new Error("start failed")
setSetupInfo(await res.json())
} catch {
setError("Klarte ikke å starte oppsettet. Prøv igjen.")
setMethod(null)
} finally {
setStarting(false)
}
}
async function handleConfirm(e: React.FormEvent) {
e.preventDefault()
if (!method || !code.trim() || confirming) return
setConfirming(true)
setError(null)
try {
const res = await fetch("/auth/2fa/setup/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ method, code: code.trim(), secret: setupInfo?.secret }),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Feil kode.")
}
const user: SessionUser = await res.json()
onSuccess(user)
} catch (err) {
setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
} finally {
setConfirming(false)
}
}
if (!method) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
<ShieldCheck aria-hidden="true" className="size-7 text-primary" />
</div>
<div className="flex flex-col gap-1">
<h2 className="text-xl font-extrabold tracking-tight">Sett opp topartsautentisering</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{forced
? "Som organisasjonseier/administrator må du sette opp 2FA før du kan fortsette."
: "Velg hvilken metode du ønsker."}
</p>
</div>
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
{error}
</p>
)}
<div className="flex flex-col gap-3">
<Button
type="button"
disabled={starting}
onClick={() => void chooseMethod("totp")}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
<KeyRound aria-hidden="true" className="size-5" />
Autentisator-app (TOTP)
</Button>
<Button
type="button"
variant="outline"
disabled={starting}
onClick={() => void chooseMethod("email")}
className="h-14 rounded-2xl text-base font-bold"
>
<Mail aria-hidden="true" className="size-5" />
Engangskode e-post
</Button>
</div>
</div>
)
}
return (
<form onSubmit={handleConfirm} className="flex flex-col gap-6" noValidate>
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
<ShieldCheck aria-hidden="true" className="size-7 text-primary" />
</div>
<h2 className="text-xl font-extrabold tracking-tight">Bekreft oppsettet</h2>
</div>
{method === "totp" && setupInfo?.qr_code_data_uri && (
<div className="flex flex-col items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element -- data-URI, ingen next/image-fordel */}
<img
src={setupInfo.qr_code_data_uri || "/placeholder.svg"}
alt="QR-kode for autentisator-app"
className="size-48 rounded-2xl border border-border"
/>
<p className="text-center text-xs leading-relaxed text-muted-foreground text-pretty">
Skann QR-koden med autentisator-appen din, eller skriv inn denne koden manuelt:{" "}
<span className="font-mono font-semibold text-foreground">{setupInfo.secret}</span>
</p>
</div>
)}
{method === "email" && (
<p className="text-center text-sm text-muted-foreground text-pretty">
Vi har sendt en engangskode til e-posten din.
</p>
)}
<div className="flex flex-col gap-2">
<Label htmlFor="setup-code" className="text-sm font-semibold">
Bekreftelseskode
</Label>
<Input
id="setup-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value)}
className="h-14 rounded-2xl text-base tracking-widest"
/>
</div>
{error && (
<p role="alert" className="text-center text-sm font-medium text-destructive">
{error}
</p>
)}
<Button
type="submit"
disabled={confirming || !code.trim()}
className="h-14 rounded-2xl text-base font-bold shadow-sm"
>
{confirming ? "Bekrefter …" : "Aktiver 2FA"}
</Button>
<button
type="button"
onClick={() => {
setMethod(null)
setSetupInfo(null)
setCode("")
}}
className="inline-flex items-center justify-center gap-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-4" />
Velg en annen metode
</button>
</form>
)
}

View file

@ -7,8 +7,15 @@ import { CheckCircle2, KeyRound } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { TwoFactorSetupForm, TwoFactorVerifyForm } from "@/components/two-factor-flow"
type Status = "checking" | "verifying" | "manual" | "success" | "error" type Status = "checking" | "verifying" | "manual" | "success" | "error" | "2fa-verify" | "2fa-setup"
type LoginResult = {
status: "success" | "2fa_required" | "2fa_setup_required"
user?: { email: string }
two_factor_method?: "totp" | "email"
}
export function VerifyForm() { export function VerifyForm() {
const router = useRouter() const router = useRouter()
@ -18,6 +25,7 @@ export function VerifyForm() {
const [status, setStatus] = useState<Status>(urlToken ? "checking" : "manual") const [status, setStatus] = useState<Status>(urlToken ? "checking" : "manual")
const [manualToken, setManualToken] = useState("") const [manualToken, setManualToken] = useState("")
const [email, setEmail] = useState<string | null>(null) const [email, setEmail] = useState<string | null>(null)
const [twoFactorMethod, setTwoFactorMethod] = useState<"totp" | "email">("totp")
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
async function verify(token: string) { async function verify(token: string) {
@ -34,8 +42,17 @@ export function VerifyForm() {
const body = await res.json().catch(() => null) const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Lenken er ugyldig eller utløpt.") throw new Error(body?.detail?.message ?? "Lenken er ugyldig eller utløpt.")
} }
const user = await res.json() const result: LoginResult = await res.json()
setEmail(user.email) if (result.status === "2fa_required") {
setTwoFactorMethod(result.two_factor_method ?? "totp")
setStatus("2fa-verify")
return
}
if (result.status === "2fa_setup_required") {
setStatus("2fa-setup")
return
}
setEmail(result.user?.email ?? null)
setStatus("success") setStatus("success")
// Kort pause så brukeren rekker å se bekreftelsen før vi går videre. // Kort pause så brukeren rekker å se bekreftelsen før vi går videre.
setTimeout(() => router.replace("/dashboard"), 900) setTimeout(() => router.replace("/dashboard"), 900)
@ -45,6 +62,10 @@ export function VerifyForm() {
} }
} }
function handleTwoFactorSuccess() {
router.replace("/dashboard")
}
useEffect(() => { useEffect(() => {
if (urlToken) void verify(urlToken) if (urlToken) void verify(urlToken)
// Kjør kun ved mount / når URL-tokenet faktisk endres. // Kjør kun ved mount / når URL-tokenet faktisk endres.
@ -57,6 +78,26 @@ export function VerifyForm() {
void verify(manualToken.trim()) void verify(manualToken.trim())
} }
if (status === "2fa-verify") {
return (
<div className="w-full rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
<TwoFactorVerifyForm
method={twoFactorMethod}
onSuccess={handleTwoFactorSuccess}
onBack={() => setStatus("manual")}
/>
</div>
)
}
if (status === "2fa-setup") {
return (
<div className="w-full rounded-3xl border border-border bg-card p-6 shadow-lg shadow-black/5 sm:p-8">
<TwoFactorSetupForm forced onSuccess={handleTwoFactorSuccess} />
</div>
)
}
if (status === "checking" || status === "verifying") { if (status === "checking" || status === "verifying") {
return ( 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 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">

View file

@ -14,6 +14,7 @@ const nextConfig = {
{ source: "/auth/:path*", destination: `${API_ORIGIN}/auth/:path*` }, { source: "/auth/:path*", destination: `${API_ORIGIN}/auth/:path*` },
{ source: "/orgs/:path*", destination: `${API_ORIGIN}/orgs/:path*` }, { source: "/orgs/:path*", destination: `${API_ORIGIN}/orgs/:path*` },
{ source: "/public/:path*", destination: `${API_ORIGIN}/public/:path*` }, { source: "/public/:path*", destination: `${API_ORIGIN}/public/:path*` },
{ source: "/superadmin/:path*", destination: `${API_ORIGIN}/superadmin/:path*` },
{ source: "/health", destination: `${API_ORIGIN}/health` }, { source: "/health", destination: `${API_ORIGIN}/health` },
] ]
}, },