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
2026-07-19 10:36:50 +02:00
|
|
|
"use client"
|
|
|
|
|
|
Gjør /logg-inn til den ekte innloggingssiden
/logg-inn (clubhouse-palett, TeeCupAuth) var en foreldreløs V0-
utforskning -- ingenting i appen lenket eller redirectet dit, og
TeeCupAuth var 100% mock (sleep()-baserte stubber, hardkodet
demo-passord, falske invitasjonskoder, en falsk "2FA-kode sendes"-tekst
som aldri sendte noe). Flagget eksplisitt til bruker før arbeid startet,
siden dette er sikkerhetskritisk kode og omfanget var langt større enn
en ren redirect-ombytting.
Full port: ekte POST /auth/request-link, POST /auth/login-password
(identisk LoginResult-statushåndtering som den gamle LoginForm), GET
/public/tournaments/by-code/{code}. TwoFactorVerifyForm/
TwoFactorSetupForm gjenbrukt UENDRET (lavest mulig risiko for
sikkerhetskritisk 2FA-kode). app/page.tsx redusert til en tynn
videresending (autentisert -> /dashboard//account, uautentisert ->
/logg-inn), beholdt for gamle bokmerker. Gammel LoginForm slettet.
Alle ~11 uautentisert-redirect-steder (committet i forrige commit)
pekte allerede til /logg-inn.
Én reell bug funnet og rettet under scratch-verifisering:
app/logg-inn/page.tsx kalte redirect() INNI en try/catch som fanget
Next.js sin egen NEXT_REDIRECT-kontrollflyt-exception stille -- en
allerede innlogget bruker fikk se innloggingsskjemaet på nytt i stedet
for å bli sendt videre. Rettet ved å flytte redirect()-kallet utenfor
try/catch, samme mønster som den opprinnelige /-siden alltid brukte.
Se CHANGELOG.md punkt 42 for full verifiseringsdetalj.
2026-08-08 12:34:50 +02:00
|
|
|
// Delt mellom teecup-auth.tsx (passord-modus, /logg-inn) 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.
|
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
2026-07-19 10:36:50 +02:00
|
|
|
|
|
|
|
|
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">
|
2026-08-02 16:34:54 +02:00
|
|
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
|
|
|
|
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
|
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
2026-07-19 10:36:50 +02:00
|
|
|
</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">
|
2026-08-02 16:34:54 +02:00
|
|
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
|
|
|
|
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
|
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
2026-07-19 10:36:50 +02:00
|
|
|
</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">
|
2026-08-02 16:34:54 +02:00
|
|
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
|
|
|
|
<ShieldCheck aria-hidden="true" className="size-7 text-muted-foreground" />
|
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
2026-07-19 10:36:50 +02:00
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|