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:
parent
73f3c4d1ea
commit
89a803140e
15 changed files with 2327 additions and 80 deletions
115
012_password_2fa_and_org_invitations.sql
Normal file
115
012_password_2fa_and_org_invitations.sql
Normal 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;
|
||||||
175
app/auth.py
175
app/auth.py
|
|
@ -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å på at denne sjekken skjer
|
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.
|
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 på JWT-en) gir
|
Det siste steget (et ekte DB-oppslag, ikke bare å stole på 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 på en ellers gyldig token.
|
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)
|
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), 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:
|
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:
|
||||||
|
|
|
||||||
68
app/email.py
68
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
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 på
|
ellers dev-only konsoll-logging bak settings.DEV_LOG_MAGIC_LINKS (aldri på
|
||||||
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 på token-raden, og brukes BÅDE til å velge språk
|
(verify-link ELLER login-password) utsteder ALDRI direkte en full sesjon --
|
||||||
på 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 på) eller må settes opp FØRST (org-eier/admin
|
||||||
overskrevet -- kun e-posten sendes på 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 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 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 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:
|
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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
)
|
||||||
|
|
|
||||||
5
frontend/app/account/page.tsx
Normal file
5
frontend/app/account/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { AccountSettings } from "@/components/account-settings"
|
||||||
|
|
||||||
|
export default function AccountPage() {
|
||||||
|
return <AccountSettings />
|
||||||
|
}
|
||||||
13
frontend/app/orgs/[id]/members/page.tsx
Normal file
13
frontend/app/orgs/[id]/members/page.tsx
Normal 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"} />
|
||||||
|
}
|
||||||
238
frontend/components/account-settings.tsx
Normal file
238
frontend/components/account-settings.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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 på e-post.
|
Ingen passord nødvendig. Vi sender deg en sikker lenke på 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? Gå rett til turneringen
|
Har du en invitasjonskode? Gå 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 på 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). ---------
|
||||||
|
|
|
||||||
366
frontend/components/org-members.tsx
Normal file
366
frontend/components/org-members.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
370
frontend/components/two-factor-flow.tsx
Normal file
370
frontend/components/two-factor-flow.tsx
Normal 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 på 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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` },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue