"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; import { Check, Copy, Download, Loader2, RefreshCw, ShieldCheck, ShieldOff, Smartphone, } from "lucide-react"; import { cn, formatDateTime } from "@/lib/utils"; /** * Zwei-Faktor-Anmeldung per Einmalkennwort. * * Die Einrichtung ist bewusst zweistufig: erst der QR-Code, dann ein Code zur * Bestaetigung. Scharf wird der Schluessel erst danach – sonst waere jemand, * der den QR-Code nicht scannen konnte, beim naechsten Anmelden ausgesperrt. */ export interface TotpState { enabled: boolean; enabledAt: string | Date | null; recoveryCodesLeft: number; } type Notice = { ok: boolean; text: string } | null; export function TotpSection({ state, hasPassword, }: { state: TotpState; hasPassword: boolean; }) { const router = useRouter(); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null); // Einrichtung const [setup, setSetup] = useState<{ qr: string; secret: string } | null>(null); const [code, setCode] = useState(""); // Wird genau einmal angezeigt, direkt nach dem Erzeugen. const [codes, setCodes] = useState(null); // Abschalten und Neuerzeugen verlangen beides das Passwort. const [password, setPassword] = useState(""); const [confirming, setConfirming] = useState<"disable" | "recovery" | null>(null); async function call(url: string, method: string, body?: unknown) { const response = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body ?? {}), }); const payload = await response.json().catch(() => ({})); if (!response.ok) throw new Error(payload.error ?? "Das hat nicht geklappt."); return payload; } async function start() { setBusy(true); setNotice(null); try { const payload = await call("/api/account/totp", "POST"); setSetup({ qr: payload.qr, secret: payload.secret }); } catch (caught) { setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); } finally { setBusy(false); } } async function confirm(event: React.FormEvent) { event.preventDefault(); setBusy(true); setNotice(null); try { const payload = await call("/api/account/totp", "PUT", { code }); setSetup(null); setCode(""); setCodes(payload.recoveryCodes); router.refresh(); } catch (caught) { setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); } finally { setBusy(false); } } async function submitPassword(event: React.FormEvent) { event.preventDefault(); setBusy(true); setNotice(null); try { if (confirming === "disable") { await call("/api/account/totp", "DELETE", { password }); setNotice({ ok: true, text: "Die Zwei-Faktor-Anmeldung ist abgeschaltet." }); setCodes(null); } else { const payload = await call("/api/account/totp/recovery", "POST", { password }); setCodes(payload.recoveryCodes); setNotice({ ok: true, text: "Neue Codes erzeugt. Die alten gelten nicht mehr." }); } setConfirming(null); setPassword(""); router.refresh(); } catch (caught) { setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) }); } finally { setBusy(false); } } return (

Zwei-Faktor-Anmeldung

{state.enabled ? `Aktiv seit ${formatDateTime(state.enabledAt)}. Nach dem Passwort fragt die Anmeldung nach einem Code.` : "Ein zeitbasiertes Einmalkennwort zusätzlich zum Passwort — aus einer Authenticator-App."}

{!hasPassword ? (

Dieses Konto hat kein Passwort und meldet sich per Passkey an. Ein zweiter Faktor käme dabei nie zum Einsatz — der Passkey ist bereits an dein Gerät gebunden.

) : null} {/* Frisch erzeugte Wiederherstellungscodes, einmalige Anzeige. */} {codes ? setCodes(null)} /> : null} {hasPassword && !state.enabled && !setup && !codes ? ( ) : null} {/* Schritt 1: QR-Code scannen, Schritt 2: Code bestaetigen. */} {setup ? (
{/* eslint-disable-next-line @next/next/no-img-element -- data-URI, kein Netzabruf */} QR-Code für die Authenticator-App

Scanne den Code mit deiner Authenticator-App — etwa Aegis, 2FAS, Ente Auth oder dem Passwortmanager deiner Wahl.

Geht die Kamera nicht, trage den Schlüssel von Hand ein:

{setup.secret}
setCode(event.target.value)} inputMode="numeric" autoComplete="one-time-code" maxLength={7} required autoFocus />
) : null} {state.enabled && !setup ? (

{state.recoveryCodesLeft > 0 ? ( <> Noch {state.recoveryCodesLeft}{" "} Wiederherstellungscodes übrig. ) : ( Keine Wiederherstellungscodes mehr übrig. Ohne sie hilft bei einem verlorenen Gerät nichts mehr — bitte neue erzeugen. )}

{confirming ? (
setPassword(event.target.value)} autoComplete="current-password" required autoFocus />
) : (
)}
) : null} {notice ? (

{notice.text}

) : null}
); } /** * Die Codes werden nur dieses eine Mal gezeigt. Deshalb Kopieren und * Herunterladen direkt daneben – und ein Knopf, der das Wegklicken zu einer * bewussten Handlung macht. */ function RecoveryCodes({ codes, onDone }: { codes: string[]; onDone: () => void }) { const [copied, setCopied] = useState(false); const text = codes.join("\n"); async function copy() { try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { // Ohne Zwischenablage-Recht bleibt Markieren von Hand. } } function download() { const url = URL.createObjectURL(new Blob([`${text}\n`], { type: "text/plain" })); const link = document.createElement("a"); link.href = url; link.download = "nebula-wiederherstellungscodes.txt"; link.click(); URL.revokeObjectURL(url); } return (

Wiederherstellungscodes

Jeder Code gilt einmal. Bewahre sie an einem sicheren Ort auf — sie sind der einzige Weg zurück, wenn dein Gerät verloren geht. Sie werden nur jetzt angezeigt.

    {codes.map((entry) => (
  • {entry}
  • ))}
); }