371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
|
|
"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>
|
||
|
|
)
|
||
|
|
}
|