hellth-hub/apps/admin/app/login/otp/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

188 lines
6.6 KiB
TypeScript

"use client";
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
const OTP_STEP_STORAGE_KEY = "hellthhub_login_step_otp";
function clearOtpStep() {
try {
window.localStorage.removeItem(OTP_STEP_STORAGE_KEY);
} catch {
// ignore storage access issues
}
}
export default function OtpLoginPage() {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [method, setMethod] = useState<"totp" | "backup">("totp");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const code = inputRef.current?.value?.trim() ?? "";
const normalizedCode = method === "totp" ? code.replace(/\D/g, "") : code;
if (method === "totp" && normalizedCode.length !== 6) {
setError("Bitte den 6-stelligen OTP-Code eingeben.");
return;
}
if (method === "backup" && normalizedCode.length < 6) {
setError("Bitte einen gültigen Backup-Code eingeben.");
return;
}
const verifyResult =
method === "totp"
? await authClient.twoFactor.verifyTotp({ code: normalizedCode })
: await authClient.twoFactor.verifyBackupCode({ code: normalizedCode });
if (verifyResult.error) {
const message =
verifyResult.error.message ??
(method === "totp"
? "OTP-Code ungültig. Bitte erneut prüfen."
: "Backup-Code ungültig. Bitte erneut prüfen.");
if (
message.includes("session") ||
message.includes("Session") ||
message.includes("expired")
) {
setError(
"OTP-Sitzung abgelaufen. Bitte erneut mit E-Mail und Passwort anmelden.",
);
return;
}
setError(
method === "totp"
? "OTP-Code ungültig. Bitte erneut prüfen."
: "Backup-Code ungültig. Bitte erneut prüfen.",
);
return;
}
clearOtpStep();
if (inputRef.current) {
inputRef.current.value = "";
}
router.push("/");
} catch {
setError("2FA-Prüfung derzeit nicht möglich. Bitte erneut versuchen.");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-[#F9F9F9]">
<div className="w-full max-w-sm">
<div className="flex flex-col items-center mb-8">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-[#0F766E] text-lg font-bold text-white shadow-sm">
HH
</div>
<h1 className="text-xl font-bold text-[#333333]">Hellth Hub</h1>
<p className="text-sm text-gray-500 mt-2">OTP-Verifizierung</p>
</div>
<form
onSubmit={handleSubmit}
className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-4"
>
<div className="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1">
<button
type="button"
onClick={() => {
setMethod("totp");
setError(null);
if (inputRef.current) inputRef.current.value = "";
}}
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
method === "totp" ? "bg-white text-gray-900 shadow-sm" : "text-gray-600"
}`}
>
Authenticator-Code
</button>
<button
type="button"
onClick={() => {
setMethod("backup");
setError(null);
if (inputRef.current) inputRef.current.value = "";
}}
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
method === "backup" ? "bg-white text-gray-900 shadow-sm" : "text-gray-600"
}`}
>
Backup-Code
</button>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-red-600 bg-[#ECFDF5] border border-red-100 px-3 py-2.5 rounded-lg">
<span className="w-4 h-4 text-[#0F766E] shrink-0">!</span>
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-[#333333] mb-1.5">
{method === "totp" ? "OTP-Code (6-stellig)" : "Backup-Code"}
</label>
<input
ref={inputRef}
type="text"
required
autoFocus
suppressHydrationWarning
name={method === "totp" ? "one-time-code" : "backup-code"}
autoComplete={method === "totp" ? "one-time-code" : "off"}
inputMode={method === "totp" ? "numeric" : "text"}
pattern={method === "totp" ? "[0-9]{6}" : undefined}
maxLength={method === "totp" ? 6 : 64}
onInput={(e) => {
const input = e.currentTarget;
if (method === "totp") {
input.value = input.value.replace(/\D/g, "").slice(0, 6);
}
}}
placeholder={method === "totp" ? "123456" : "z. B. ABCD-EFGH"}
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm text-[#333333] placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-[#0F766E] focus:border-transparent transition-shadow"
/>
{method === "backup" && (
<p className="mt-1 text-xs text-gray-500">
Jeder Backup-Code ist nur einmal gültig.
</p>
)}
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-[#0F766E] text-white text-sm font-semibold rounded-lg hover:bg-[#115E59] transition-colors disabled:opacity-50 disabled:cursor-not-allowed mt-2"
>
{loading ? "Prüfen..." : method === "totp" ? "OTP prüfen" : "Backup-Code prüfen"}
</button>
<button
type="button"
onClick={() => {
clearOtpStep();
router.replace("/login");
}}
className="w-full py-2.5 bg-white text-[#333333] text-sm font-semibold rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
>
Zurück zu Login
</button>
</form>
</div>
</div>
);
}