hellth-hub/apps/admin/app/(authed)/sicherheit/2fa-setup/page.tsx
Sebastian Mayer 2436dd9cac Initial commit: Hellth Hub monorepo
Mobile-first Web-App fuer persoenliches Health-, Ernaehrungs-,
Aktivitaets- und Einkaufs-Tracking im Home-Lab-Betrieb.

Stack: pnpm Monorepo - Next.js 16 + React 19 + Tailwind (apps/admin) -
Hono + tRPC + Better Auth mit 2FA und Passkeys (packages/api) -
PostgreSQL + Drizzle ORM (packages/db) - Docker fuer Postgres und pgAdmin.

Module:
- Auth mit Login, 2FA (TOTP + Backup-Codes), Passkeys und Admin-Recovery
- Tagesansicht mit Essen, Trinken, Gewicht, Aktivitaeten und Coach-Hinweisen
- Lebensmittelkatalog mit Naehrwerten, Portionen, OCR/Barcode/Open Food Facts
- Rezepte mit Live-Naehrwertberechnung und Community-Vorschlaegen
- Essensplanung und Wochenbudget mit Sportbonus
- Einkaufslisten mit Maerkten und Laufweg-Sortierung
- Hellfireclub mit Aktivitaetspunkten und gemeinsamer Aktivitaetsplanung
- Wearables-Vorbereitung (Fitbit / Google Fit / Health Connect)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:15:16 +02:00

262 lines
9 KiB
TypeScript

"use client";
import { authClient, useSession } from "@/lib/auth-client";
import { trpc } from "@/lib/trpc";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
export default function TwoFactorSetupPage() {
const router = useRouter();
const { data: session } = useSession();
const securityQuery = trpc.security.status.useQuery();
const [password, setPassword] = useState("");
const [otpCode, setOtpCode] = useState("");
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [totpUri, setTotpUri] = useState<string | null>(null);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const qrCodeUrl = useMemo(() => {
if (!totpUri) return null;
const sanitized = totpUri.trim();
return `https://api.qrserver.com/v1/create-qr-code/?size=320x320&margin=16&data=${encodeURIComponent(
sanitized,
)}`;
}, [totpUri]);
const manualSecret = useMemo(() => {
if (!totpUri) return null;
const match = totpUri.match(/[?&]secret=([^&]+)/i);
if (!match?.[1]) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return match[1];
}
}, [totpUri]);
const requiresSetup = securityQuery.data?.requiresTwoFactorSetup ?? false;
async function handleEnableTwoFactor() {
setErrorMessage(null);
setStatusMessage(null);
setLoading(true);
try {
const result = (await authClient.twoFactor.enable({
password,
issuer: "Hellth Hub",
})) as unknown as {
error?: { message?: string };
data?: { totpURI?: string; backupCodes?: string[] };
totpURI?: string;
backupCodes?: string[];
};
if (result?.error) {
setErrorMessage(result.error.message ?? "2FA konnte nicht aktiviert werden.");
return;
}
const data = result.data ?? result;
setTotpUri(data.totpURI ?? null);
setBackupCodes(data.backupCodes ?? []);
setStatusMessage(
"OTP vorbereitet. Bitte QR-Code scannen und danach den 6-stelligen Code bestätigen.",
);
setPassword("");
await securityQuery.refetch();
} catch {
setErrorMessage("2FA konnte derzeit nicht aktiviert werden.");
} finally {
setLoading(false);
}
}
async function handleVerifyTotp() {
setErrorMessage(null);
setStatusMessage(null);
setLoading(true);
try {
if (!totpUri) {
setErrorMessage(
"OTP ist noch nicht vorbereitet. Bitte zuerst in Schritt 1 mit deinem Passwort aktivieren.",
);
return;
}
const result = (await authClient.twoFactor.verifyTotp({
code: otpCode.trim(),
})) as unknown as {
error?: { message?: string };
};
if (result?.error) {
const message = result.error.message ?? "OTP-Code ist ungültig.";
if (message.toLowerCase().includes("totp not enabled")) {
setErrorMessage(
"TOTP ist noch nicht aktiviert. Bitte Schritt 1 (OTP aktivieren) ausführen.",
);
return;
}
setErrorMessage(message);
return;
}
setStatusMessage("2FA erfolgreich aktiviert.");
setOtpCode("");
const refreshed = await securityQuery.refetch();
if (!refreshed.data?.requiresTwoFactorSetup) {
router.push("/");
}
} catch {
setErrorMessage("OTP-Code konnte nicht verifiziert werden.");
} finally {
setLoading(false);
}
}
return (
<div className="max-w-4xl mx-auto space-y-6">
<div className="bg-white border border-gray-200 rounded-xl p-6">
<h1 className="text-xl font-bold text-[#333333]">Sicherheits-Setup</h1>
<p className="text-sm text-gray-600 mt-2">
Für Admin-Accounts ist 2FA verpflichtend. Bitte richte jetzt OTP (Authenticator-App)
ein.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<h2 className="text-base font-semibold text-[#333333]">1. OTP aktivieren</h2>
<p className="text-sm text-gray-600">
Gib dein aktuelles Passwort ein, um die OTP-Einrichtung zu starten.
</p>
<input
type="password"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
placeholder="Aktuelles Passwort"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<button
onClick={handleEnableTwoFactor}
disabled={loading || !password}
className="px-4 py-2 rounded-lg bg-[#0F766E] text-white text-sm disabled:opacity-50"
>
{loading ? "Aktiviere..." : "OTP aktivieren"}
</button>
</section>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<h2 className="text-base font-semibold text-[#333333]">2. QR-Code scannen</h2>
{!qrCodeUrl && (
<p className="text-sm text-gray-500">
Nach der Aktivierung erscheint hier der QR-Code für deine Authenticator-App.
</p>
)}
{qrCodeUrl && (
<div className="space-y-3">
<img
src={qrCodeUrl}
alt="OTP QR Code"
className="w-72 h-72 rounded-lg bg-white p-2"
/>
<p className="text-xs text-gray-500 break-all">{totpUri}</p>
{manualSecret && (
<div className="text-xs text-gray-700 bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
Falls Scannen nicht klappt, manuell eingeben:{" "}
<code className="font-mono">{manualSecret}</code>
</div>
)}
</div>
)}
</section>
</div>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<h2 className="text-base font-semibold text-[#333333]">3. Backup-Codes sichern</h2>
{backupCodes.length === 0 ? (
<p className="text-sm text-gray-500">
Nach Aktivierung werden hier Backup-Codes angezeigt.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{backupCodes.map((code) => (
<code
key={code}
className="px-3 py-2 rounded bg-gray-100 text-sm font-mono text-gray-800"
>
{code}
</code>
))}
</div>
)}
</section>
<section className="bg-white border border-gray-200 rounded-xl p-5 space-y-4">
<h2 className="text-base font-semibold text-[#333333]">4. OTP-Code bestätigen</h2>
<p className="text-sm text-gray-600">
Bitte den aktuellen 6-stelligen Code aus deiner Authenticator-App eingeben.
</p>
<input
className="w-full max-w-xs border border-gray-200 rounded-lg px-3 py-2 text-sm tracking-[0.3em]"
placeholder="123456"
maxLength={6}
value={otpCode}
onChange={(event) =>
setOtpCode(event.target.value.replace(/\D/g, "").slice(0, 6))
}
/>
<button
onClick={handleVerifyTotp}
disabled={loading || otpCode.length !== 6 || !totpUri}
className="px-4 py-2 rounded-lg bg-gray-900 text-white text-sm disabled:opacity-50"
>
OTP bestätigen
</button>
</section>
<section className="bg-white border border-amber-200 rounded-xl p-5 space-y-2">
<h2 className="text-base font-semibold text-amber-800">Passkey Hinweis</h2>
<p className="text-sm text-amber-700">
Passkey ist jetzt verfügbar. Du kannst in den Einstellungen unter Sicherheit einen
Passkey hinterlegen. Für Admin-Accounts bleibt OTP weiterhin verpflichtend.
</p>
</section>
{statusMessage && (
<div className="text-sm rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-green-800">
{statusMessage}
</div>
)}
{errorMessage && (
<div className="text-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-red-700">
{errorMessage}
</div>
)}
<div className="flex items-center gap-3">
<button
onClick={() => securityQuery.refetch()}
className="px-4 py-2 rounded-lg bg-gray-100 text-gray-700 text-sm"
>
Status aktualisieren
</button>
<button
onClick={() => router.push("/")}
disabled={requiresSetup}
className="px-4 py-2 rounded-lg bg-gray-900 text-white text-sm disabled:opacity-50"
>
Zum Dashboard
</button>
</div>
{!session && (
<div className="text-sm rounded-lg border border-gray-200 bg-white px-4 py-3 text-gray-600">
Keine aktive Session gefunden. Bitte erneut einloggen.
</div>
)}
</div>
);
}