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>
272 lines
8.5 KiB
TypeScript
272 lines
8.5 KiB
TypeScript
"use client";
|
|
|
|
import { authClient, resolveApiBaseUrl, signIn } from "@/lib/auth-client";
|
|
import { useRouter } from "next/navigation";
|
|
import { useEffect, useState } from "react";
|
|
|
|
const OTP_STEP_STORAGE_KEY = "hellthhub_login_step_otp";
|
|
const OTP_STEP_TTL_MS = 5 * 60 * 1000;
|
|
|
|
function hasActiveOtpStep(): boolean {
|
|
try {
|
|
const raw = window.localStorage.getItem(OTP_STEP_STORAGE_KEY);
|
|
if (!raw) return false;
|
|
const parsed = JSON.parse(raw) as { expiresAt?: number };
|
|
if (!parsed?.expiresAt || Date.now() > parsed.expiresAt) {
|
|
window.localStorage.removeItem(OTP_STEP_STORAGE_KEY);
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function setOtpStepActive() {
|
|
try {
|
|
window.localStorage.setItem(
|
|
OTP_STEP_STORAGE_KEY,
|
|
JSON.stringify({ expiresAt: Date.now() + OTP_STEP_TTL_MS }),
|
|
);
|
|
} catch {
|
|
// ignore storage access issues
|
|
}
|
|
}
|
|
|
|
function clearOtpStep() {
|
|
try {
|
|
window.localStorage.removeItem(OTP_STEP_STORAGE_KEY);
|
|
} catch {
|
|
// ignore storage access issues
|
|
}
|
|
}
|
|
|
|
async function assertAuthApiReachable() {
|
|
const controller = new AbortController();
|
|
const timeout = window.setTimeout(() => controller.abort(), 3000);
|
|
try {
|
|
const response = await fetch(`${resolveApiBaseUrl().replace(/\/$/, "")}/api/health`, {
|
|
cache: "no-store",
|
|
signal: controller.signal,
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
} finally {
|
|
window.clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
export default function LoginPage() {
|
|
const router = useRouter();
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [otpStepChecked, setOtpStepChecked] = useState(false);
|
|
const [passkeyAutofillTried, setPasskeyAutofillTried] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (hasActiveOtpStep()) {
|
|
router.replace("/login/otp");
|
|
return;
|
|
}
|
|
setOtpStepChecked(true);
|
|
}, [router]);
|
|
|
|
useEffect(() => {
|
|
if (!otpStepChecked || passkeyAutofillTried) return;
|
|
|
|
async function tryPasskeyAutofill() {
|
|
setPasskeyAutofillTried(true);
|
|
if (typeof window === "undefined" || !("PublicKeyCredential" in window)) {
|
|
return;
|
|
}
|
|
|
|
const pk = window.PublicKeyCredential as typeof PublicKeyCredential & {
|
|
isConditionalMediationAvailable?: () => Promise<boolean>;
|
|
};
|
|
if (typeof pk.isConditionalMediationAvailable !== "function") {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const available = await pk.isConditionalMediationAvailable();
|
|
if (!available) return;
|
|
|
|
const result = await authClient.signIn.passkey({ autoFill: true });
|
|
const hasTwoFactorChallenge =
|
|
Boolean(result.data && "twoFactorRedirect" in result.data) ||
|
|
Boolean((result as { twoFactorRedirect?: boolean }).twoFactorRedirect);
|
|
|
|
if (result.error) return;
|
|
if (hasTwoFactorChallenge) {
|
|
setOtpStepActive();
|
|
router.push("/login/otp");
|
|
return;
|
|
}
|
|
clearOtpStep();
|
|
router.push("/");
|
|
} catch {
|
|
// Best-effort only; manual login remains available.
|
|
}
|
|
}
|
|
|
|
void tryPasskeyAutofill();
|
|
}, [otpStepChecked, passkeyAutofillTried, router]);
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setLoading(true);
|
|
|
|
try {
|
|
await assertAuthApiReachable();
|
|
const result = await signIn.email({ email, password });
|
|
const hasTwoFactorChallenge =
|
|
Boolean(result.data && "twoFactorRedirect" in result.data) ||
|
|
Boolean((result as { twoFactorRedirect?: boolean }).twoFactorRedirect);
|
|
|
|
if (result.error) {
|
|
setError("E-Mail oder Passwort ungültig.");
|
|
return;
|
|
}
|
|
|
|
if (hasTwoFactorChallenge) {
|
|
setOtpStepActive();
|
|
router.push("/login/otp");
|
|
return;
|
|
}
|
|
|
|
clearOtpStep();
|
|
|
|
router.push("/");
|
|
} catch {
|
|
setError(
|
|
"Login derzeit nicht möglich. Bitte prüfe, ob API/Auth auf Port 3002 läuft.",
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handlePasskeyLogin() {
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
const result = await authClient.signIn.passkey();
|
|
const hasTwoFactorChallenge =
|
|
Boolean(result.data && "twoFactorRedirect" in result.data) ||
|
|
Boolean((result as { twoFactorRedirect?: boolean }).twoFactorRedirect);
|
|
|
|
if (result.error) {
|
|
const message = (result.error.message ?? "").toLowerCase();
|
|
if (message.includes("cancel")) {
|
|
setError("Passkey-Vorgang abgebrochen.");
|
|
return;
|
|
}
|
|
if (
|
|
window.location.hostname !== "localhost" &&
|
|
(message.includes("origin") || message.includes("rp") || message.includes("domain"))
|
|
) {
|
|
setError(
|
|
"Passkey auf lokaler Entwicklung bitte unter http://localhost:3001 nutzen (nicht 127.0.0.1 oder ::1).",
|
|
);
|
|
return;
|
|
}
|
|
setError(result.error.message ?? "Passkey-Anmeldung fehlgeschlagen. Bitte erneut versuchen.");
|
|
return;
|
|
}
|
|
|
|
if (hasTwoFactorChallenge) {
|
|
setOtpStepActive();
|
|
router.push("/login/otp");
|
|
return;
|
|
}
|
|
|
|
clearOtpStep();
|
|
router.push("/");
|
|
} catch {
|
|
setError("Passkey-Anmeldung derzeit nicht möglich.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-[#F9F9F9]">
|
|
{!otpStepChecked ? (
|
|
<div className="w-8 h-8 rounded-full border-2 border-[#0F766E] border-t-transparent animate-spin" />
|
|
) : (
|
|
<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="mt-2 text-sm text-gray-500">Health-Tracking für dein Home-Lab</p>
|
|
</div>
|
|
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="bg-white rounded-xl shadow-sm border border-gray-100 p-6 space-y-4"
|
|
>
|
|
{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">
|
|
E-Mail
|
|
</label>
|
|
<input
|
|
type="email"
|
|
required
|
|
autoComplete="username webauthn"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="admin@example.com"
|
|
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"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[#333333] mb-1.5">
|
|
Passwort
|
|
</label>
|
|
<input
|
|
type="password"
|
|
required
|
|
autoComplete="current-password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder="************"
|
|
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"
|
|
/>
|
|
</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 ? "Anmelden..." : "Anmelden"}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handlePasskeyLogin}
|
|
disabled={loading}
|
|
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Mit Passkey anmelden
|
|
</button>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|