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
238 lines
8.8 KiB
TypeScript
238 lines
8.8 KiB
TypeScript
"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>
|
|
)
|
|
}
|