From 89a803140ef6ff618f3754a07ecc6138c7c187c5 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Sun, 19 Jul 2026 10:36:50 +0200 Subject: [PATCH] Update Todos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- 012_password_2fa_and_org_invitations.sql | 115 ++++++ app/auth.py | 175 ++++++++- app/email.py | 68 ++++ app/requirements.txt | 3 + app/routers/auth.py | 451 ++++++++++++++++++++--- app/routers/organizations.py | 323 +++++++++++++++- frontend/app/account/page.tsx | 5 + frontend/app/orgs/[id]/members/page.tsx | 13 + frontend/components/account-settings.tsx | 238 ++++++++++++ frontend/components/dashboard.tsx | 39 +- frontend/components/login-form.tsx | 193 +++++++++- frontend/components/org-members.tsx | 366 ++++++++++++++++++ frontend/components/two-factor-flow.tsx | 370 +++++++++++++++++++ frontend/components/verify-form.tsx | 47 ++- frontend/next.config.mjs | 1 + 15 files changed, 2327 insertions(+), 80 deletions(-) create mode 100644 012_password_2fa_and_org_invitations.sql create mode 100644 frontend/app/account/page.tsx create mode 100644 frontend/app/orgs/[id]/members/page.tsx create mode 100644 frontend/components/account-settings.tsx create mode 100644 frontend/components/org-members.tsx create mode 100644 frontend/components/two-factor-flow.tsx diff --git a/012_password_2fa_and_org_invitations.sql b/012_password_2fa_and_org_invitations.sql new file mode 100644 index 0000000..ac3e726 --- /dev/null +++ b/012_password_2fa_and_org_invitations.sql @@ -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; diff --git a/app/auth.py b/app/auth.py index 5fd857e..50f862b 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,10 +1,10 @@ """ Auth-lag for TeeCup. -Ekte autentisering: magic-link (se app/routers/auth.py for utstedelse) + -JWT-sesjon i en HttpOnly-cookie. `get_current_user` er IKKE lenger en stub — -den dekoder og verifiserer den innsendte sesjonscookien, og bekrefter at -brukeren fortsatt finnes. +Ekte autentisering: magic-link ELLER e-post+passord (ADR-021, begge fører til +samme sesjons-cookie), pluss valgfri/påkrevd 2FA (TOTP eller e-post- +engangskode). `get_current_user` dekoder og verifiserer den innsendte +sesjonscookien, og bekrefter at brukeren fortsatt finnes. `get_authorized_org` er fortsatt sikkerhetskritisk uendret: den verifiserer at 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 organisasjonens data. Isolasjonen står og faller altså på at denne sjekken skjer 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 from dataclasses import dataclass +from typing import Literal import jwt +import pyotp +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, InvalidHash from fastapi import Depends, Request from .config import settings @@ -30,18 +44,74 @@ SESSION_COOKIE_NAME = "teecup_session" _JWT_ALGORITHM = "HS256" # Eksplisitt både ved signering og dekoding -- blokkerer # 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) class CurrentUser: 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()) - 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) +# --------------------------------------------------------------------------- +# 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: """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 på JWT-en) gir faktisk tilbakekalling: en slettet/deaktivert bruker kan ikke ri ut resten av sesjonens levetid på 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` på de egne 2FA-endepunktene (se modul-docstring). """ token = request.cookies.get(SESSION_COOKIE_NAME) if not token: @@ -72,6 +146,8 @@ async def get_current_user(request: Request) -> CurrentUser: user_id = claims.get("sub") if not user_id: 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: 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) +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), så dette fungerer på 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: """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 user_id = claims.get("sub") - if not user_id: + if not user_id or claims.get("stage", "full") != "full": return None async with plain_connection() as conn: diff --git a/app/email.py b/app/email.py index 2eb82bf..8a6de21 100644 --- a/app/email.py +++ b/app/email.py @@ -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 ) 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) diff --git a/app/requirements.txt b/app/requirements.txt index 323d73e..d22be86 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -8,3 +8,6 @@ pillow>=10.0 pillow-avif-plugin>=1.4 python-multipart>=0.0.9 httpx>=0.27 +argon2-cffi>=23.1 +pyotp>=2.9 +qrcode>=7.4 diff --git a/app/routers/auth.py b/app/routers/auth.py index 2246dcb..47d74e9 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -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 nettopp fikk en lenke -- unngår at endepunktet kan brukes til å sjekke - hvilke e-poster som har konto (enumerering). - - Kun SHA-256-hashen av token lagres, aldri klarteksten. - - app_user opprettes FØRST når en gyldig, uforbrukt token løses inn i - verify-link -- IKKE når lenken bare forespørres (hindrer massopprettelse - av kontoer for e-poster man ikke eier). - - Token-forbruk er ÉN atomisk UPDATE ... RETURNING (ikke les-sjekk-skriv) - for å hindre at to samtidige forsøk med samme lenke begge lykkes. + hvilke e-poster som har konto (enumerering). login-password svarer + identisk (401 INVALID_CREDENTIALS) uansett om e-posten finnes, mangler + passord, eller passordet er feil -- samme prinsipp. + - Kun SHA-256-hashen av magic-link-token/2FA-kode lagres, aldri klartekst. + Passord hashes med Argon2id (app/auth.py sin hash_password), ikke + SHA-256 -- se ADR-021 Beslutning B. + - 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, ellers dev-only konsoll-logging bak settings.DEV_LOG_MAGIC_LINKS (aldri på - som standard). En driftsfeil i selve utsendingen (feil passord, SMTP nede, - eller ingen av delene konfigurert) logges server-side og lekker ALDRI til - klienten som en annen respons -- det ville brutt enumereringsvernet over - (klienten skal ikke kunne skille "finnes ikke" fra "utsending feilet"). + som standard). En driftsfeil i selve utsendingen logges server-side og + lekker ALDRI til klienten som en annen respons. -i18n (se plan): `locale` sendes av klienten ved forespørsel (frontend vet sitt -eget visningsspråk), lagres på token-raden, og brukes BÅDE til å velge språk -på 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 -- kun e-posten sendes på forespørselens språk. +To-stegs innlogging (ADR-021 Beslutning D/E): primær-autentisering +(verify-link ELLER login-password) utsteder ALDRI direkte en full sesjon -- +den går alltid via `_issue_login_result()`, som avgjør om 2FA kreves +(brukeren har selv slått det på) eller må settes opp FØRST (org-eier/admin +uten 2FA konfigurert), og utsteder riktig sesjons-STADIE (se app/auth.py). + +i18n (se ADR-015): `locale` sendes av klienten ved forespørsel, lagres på +token-raden, og brukes BÅDE til å velge språk på 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 secrets import traceback from datetime import datetime, timedelta, timezone +from io import BytesIO from typing import Literal +import qrcode 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 ..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 router = APIRouter(prefix="/auth", tags=["auth"]) @@ -48,8 +75,32 @@ _GENERIC_RESPONSE = { } -def _hash_token(token: str) -> str: - return hashlib.sha256(token.encode("utf-8")).hexdigest() +def _hash_secret(raw: str) -> str: + """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): @@ -86,7 +137,7 @@ async def request_magic_link(body: MagicLinkRequest) -> dict: await conn.execute( "INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, $4)", email, - _hash_token(raw_token), + _hash_secret(raw_token), expires_at, body.locale, ) @@ -122,11 +173,60 @@ class SessionUser(BaseModel): preferred_locale: str -@router.post("/verify-link", response_model=SessionUser) -async def verify_magic_link( - body: MagicLinkVerify, response: Response, request: Request -) -> SessionUser: - token_hash = _hash_token(body.token) +class LoginResult(BaseModel): + """Felles svarform for verify-link, login-password OG 2fa/verify (ADR-021 + Beslutning E) -- akkurat ÉN av de tre utfallene: + - "success": full sesjon utstedt, `user` er fylt ut. + - "2fa_required": brukeren har selv slått på 2FA -- `two_factor_method` + forteller klienten hvilken kode-innsending som trengs. + - "2fa_setup_required": org-eier/admin uten 2FA -- klienten må 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: # Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to # 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). user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email) - # Koble enhver player-rad (i HVILKEN SOM HELST org, uansett - # medlemskap) med matchende e-post til denne nå-innloggede kontoen - # (ADR-017 Beslutning B). Trygt å kjøre på HVER innlogging, ikke - # bare førstegangsopprettelse -- funksjonens WHERE-ledd berører kun - # rader som ennå ikke er koblet, så gjentatte kall er en no-op. + # Koble enhver player-rad (ADR-017) OG enhver ventende + # organisasjonsinvitasjon (ADR-022) med matchende e-post til denne + # nå-innloggede kontoen. Trygt å kjøre på HVER innlogging -- begge + # er idempotente, berører kun rader som ennå ikke er koblet/forbrukt. 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( """ SELECT id::text AS id, email::text AS email, display_name, preferred_locale 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"], + ), + ) + + +# --------------------------------------------------------------------------- +# 2FA: OPPSETT (frivillig fra kontoinnstillinger, ELLER tvunget for +# org-eier/admin -- begge veier deler samme logikk, se +# get_current_or_enrolling_user i app/auth.py) +# --------------------------------------------------------------------------- + + +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 ) - token = create_session_token(user_row["id"]) - response.set_cookie( - SESSION_COOKIE_NAME, - token, - max_age=settings.SESSION_MAX_AGE_SECONDS, - httponly=True, - samesite="lax", - secure=should_use_secure_cookies(request), - path="/", - ) + 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)) +@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") async def logout(response: Response) -> dict: response.delete_cookie(SESSION_COOKIE_NAME, path="/") @@ -206,6 +560,8 @@ class Me(BaseModel): email: str display_name: str preferred_locale: str + has_password: bool + two_factor_method: Literal["totp", "email"] | None organizations: list[MyOrg] @@ -224,7 +580,8 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: async with plain_connection() as conn: 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 """, user.user_id, @@ -247,5 +604,7 @@ async def me(user: CurrentUser = Depends(get_current_user)) -> Me: email=user_row["email"], display_name=user_row["display_name"], preferred_locale=user_row["preferred_locale"], + has_password=user_row["has_password"], + two_factor_method=user_row["two_factor_method"], organizations=organizations, ) diff --git a/app/routers/organizations.py b/app/routers/organizations.py index a31eba4..662dc27 100644 --- a/app/routers/organizations.py +++ b/app/routers/organizations.py @@ -17,13 +17,16 @@ Verifisert med en negativ kontroll (mismatchende id blir avvist) i scratch-testingen -- se plan. """ +import traceback import uuid 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 ..email import send_organization_invitation_email from ..errors import app_error, translate_db_errors router = APIRouter() @@ -105,3 +108,319 @@ async def update_organization( if row is None: raise app_error(404, "NOT_FOUND", "Organisasjonen finnes ikke.") 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"], + ) diff --git a/frontend/app/account/page.tsx b/frontend/app/account/page.tsx new file mode 100644 index 0000000..1f36a9b --- /dev/null +++ b/frontend/app/account/page.tsx @@ -0,0 +1,5 @@ +import { AccountSettings } from "@/components/account-settings" + +export default function AccountPage() { + return +} diff --git a/frontend/app/orgs/[id]/members/page.tsx b/frontend/app/orgs/[id]/members/page.tsx new file mode 100644 index 0000000..987ce3a --- /dev/null +++ b/frontend/app/orgs/[id]/members/page.tsx @@ -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 +} diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx new file mode 100644 index 0000000..79bddb1 --- /dev/null +++ b/frontend/components/account-settings.tsx @@ -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(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 ( +
+ + ) + } + + if (!me) return null + + return ( +
+
+
+ +
+
+ +
+ {settingUp2fa ? ( +
+ { + setSettingUp2fa(false) + void loadMe() + }} + /> +
+ ) : ( +
+ + +
+
+
+
+

Topartsautentisering (2FA)

+
+ + {me.two_factor_method ? ( + <> +

+ Aktivert med{" "} + + {me.two_factor_method === "totp" ? "autentisator-app" : "engangskode på e-post"} + + . +

+ + + ) : ( + <> +

+ Ikke aktivert. Anbefales for organisasjonseiere/administratorer -- da vil du + bli bedt om å sette det opp ved neste innlogging uansett. +

+ + + )} +
+
+ )} +
+
+ ) +} + +function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onChanged: () => void }) { + const [password, setPassword] = useState("") + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(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 ( +
+
+
+
+

Passord

+
+ +

+ {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."} +

+ +
+
+ + setPassword(e.target.value)} + className="h-12 rounded-xl" + /> +
+ +
+ + {error &&

{error}

} + {success &&

Passordet er oppdatert.

} + + {hasPassword && ( + + )} +
+ ) +} diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 7b78cfd..9ccf887 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -3,7 +3,8 @@ import type React from "react" import { useEffect, useState } from "react" 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 { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -185,14 +186,23 @@ export function Dashboard() {
- +
+ +
@@ -316,7 +326,16 @@ function OrganizationView({
)} - +
+ +
diff --git a/frontend/components/login-form.tsx b/frontend/components/login-form.tsx index 41cc9bc..ae25728 100644 --- a/frontend/components/login-form.tsx +++ b/frontend/components/login-form.tsx @@ -3,19 +3,41 @@ import type React from "react" import { useEffect, useRef, useState } from "react" 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 { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { TwoFactorSetupForm, TwoFactorVerifyForm } from "@/components/two-factor-flow" 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) { 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() { - const [mode, setMode] = useState<"email" | "code">("email") + const router = useRouter() + const [mode, setMode] = useState<"email" | "code" | "password">("email") + const [postAuth, setPostAuth] = useState(null) + const [twoFactorMethod, setTwoFactorMethod] = useState<"totp" | "email" | null>(null) const [email, setEmail] = useState("") const [touched, setTouched] = useState(false) const [sent, setSent] = useState(false) @@ -23,6 +45,21 @@ export function LoginForm() { const [cooldown, setCooldown] = useState(0) const [error, setError] = useState(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 showError = touched && email.length > 0 && !emailValid @@ -76,7 +113,15 @@ export function LoginForm() { return (
- {sent ? ( + {postAuth === "2fa-verify" ? ( + setPostAuth(null)} + /> + ) : postAuth === "2fa-setup" ? ( + + ) : sent ? ( ) : mode === "code" ? ( setMode("email")} /> + ) : mode === "password" ? ( + setMode("email")} /> ) : (
@@ -135,23 +182,145 @@ export function LoginForm() {

- Ingen passord. Vi sender deg en sikker lenke på e-post. + Ingen passord nødvendig. Vi sender deg en sikker lenke på e-post.

- +
+ + +
)}
) } +// --- 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(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 ( +
+
+ +
+
+
+ +
+ +
+
+
+ + {error && ( +

+ {error} +

+ )} + + + + +
+ ) +} + // --- Kode-innlogging (ADR-020): tar deg rett til en turnering via en kort, // menneske-skrivbar kode -- FØR innlogging, og uansett turneringens // synlighet (koden ER selve invitasjonen, se ADR-020 Beslutning A). --------- diff --git a/frontend/components/org-members.tsx b/frontend/components/org-members.tsx new file mode 100644 index 0000000..b6e51ee --- /dev/null +++ b/frontend/components/org-members.tsx @@ -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 = { owner: "Eier", admin: "Administrator", member: "Medlem" } + +export function OrgMembers({ organizationId, orgName }: { organizationId: string; orgName: string }) { + const [myUserId, setMyUserId] = useState(null) + const [myRole, setMyRole] = useState(null) + const [members, setMembers] = useState([]) + const [invitations, setInvitations] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+ + ) + } + + return ( +
+
+
+ +
+
+ +
+ {error && ( +

+ {error} +

+ )} + + {canInvite && } + +
+

+

+ {members.map((m) => ( + + ))} +
+ + {invitations.length > 0 && ( +
+

+

+ {invitations.map((inv) => ( +
+
+ {inv.email} + Invitert som {ROLE_LABELS[inv.role]} +
+ {(myRole === "owner" || (myRole === "admin" && inv.role === "member")) && ( + + )} +
+ ))} +
+ )} +
+
+ ) +} + +function InviteForm({ + roleOptions, + onInvite, +}: { + roleOptions: Role[] + onInvite: (email: string, role: Role) => void +}) { + const [email, setEmail] = useState("") + const [role, setRole] = useState(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 ( +
+
+ + setEmail(e.target.value)} + className="h-11 rounded-xl" + /> +
+
+ + +
+ +
+ ) +} + +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 = { 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 ( +
+
+ + {member.display_name} + {isSelf && (deg)} + + {member.email} +
+
+ {canEditRole ? ( + + ) : ( + + {ROLE_LABELS[member.role]} + + )} + {canRemove && ( + + )} +
+
+ ) +} diff --git a/frontend/components/two-factor-flow.tsx b/frontend/components/two-factor-flow.tsx new file mode 100644 index 0000000..dccc14b --- /dev/null +++ b/frontend/components/two-factor-flow.tsx @@ -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(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 ( +
+
+
+
+
+

Topartsautentisering

+

+ {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 …"} +

+
+
+ +
+ +
+ {method === "totp" ? ( +
+
+ + {error && ( +

+ {error} +

+ )} + + + + {method === "email" && ( + + )} + + +
+ ) +} + +// --- 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(null) + const [code, setCode] = useState("") + const [confirming, setConfirming] = useState(false) + const [error, setError] = useState(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 ( +
+
+
+
+
+

Sett opp topartsautentisering

+

+ {forced + ? "Som organisasjonseier/administrator må du sette opp 2FA før du kan fortsette." + : "Velg hvilken metode du ønsker."} +

+
+
+ + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+ ) + } + + return ( +
+
+
+
+

Bekreft oppsettet

+
+ + {method === "totp" && setupInfo?.qr_code_data_uri && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- data-URI, ingen next/image-fordel */} + QR-kode for autentisator-app +

+ Skann QR-koden med autentisator-appen din, eller skriv inn denne koden manuelt:{" "} + {setupInfo.secret} +

+
+ )} + + {method === "email" && ( +

+ Vi har sendt en engangskode til e-posten din. +

+ )} + +
+ + setCode(e.target.value)} + className="h-14 rounded-2xl text-base tracking-widest" + /> +
+ + {error && ( +

+ {error} +

+ )} + + + + +
+ ) +} diff --git a/frontend/components/verify-form.tsx b/frontend/components/verify-form.tsx index 3bd993e..0a32ee4 100644 --- a/frontend/components/verify-form.tsx +++ b/frontend/components/verify-form.tsx @@ -7,8 +7,15 @@ import { CheckCircle2, KeyRound } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" 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() { const router = useRouter() @@ -18,6 +25,7 @@ export function VerifyForm() { const [status, setStatus] = useState(urlToken ? "checking" : "manual") const [manualToken, setManualToken] = useState("") const [email, setEmail] = useState(null) + const [twoFactorMethod, setTwoFactorMethod] = useState<"totp" | "email">("totp") const [error, setError] = useState(null) async function verify(token: string) { @@ -34,8 +42,17 @@ export function VerifyForm() { const body = await res.json().catch(() => null) throw new Error(body?.detail?.message ?? "Lenken er ugyldig eller utløpt.") } - const user = await res.json() - setEmail(user.email) + const result: LoginResult = await res.json() + 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") // Kort pause så brukeren rekker å se bekreftelsen før vi går videre. setTimeout(() => router.replace("/dashboard"), 900) @@ -45,6 +62,10 @@ export function VerifyForm() { } } + function handleTwoFactorSuccess() { + router.replace("/dashboard") + } + useEffect(() => { if (urlToken) void verify(urlToken) // Kjør kun ved mount / når URL-tokenet faktisk endres. @@ -57,6 +78,26 @@ export function VerifyForm() { void verify(manualToken.trim()) } + if (status === "2fa-verify") { + return ( +
+ setStatus("manual")} + /> +
+ ) + } + + if (status === "2fa-setup") { + return ( +
+ +
+ ) + } + if (status === "checking" || status === "verifying") { return (
diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 0e41a12..67c775c 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -14,6 +14,7 @@ const nextConfig = { { source: "/auth/:path*", destination: `${API_ORIGIN}/auth/:path*` }, { source: "/orgs/:path*", destination: `${API_ORIGIN}/orgs/:path*` }, { source: "/public/:path*", destination: `${API_ORIGIN}/public/:path*` }, + { source: "/superadmin/:path*", destination: `${API_ORIGIN}/superadmin/:path*` }, { source: "/health", destination: `${API_ORIGIN}/health` }, ] },