2026-05-27 22:15:16 +02:00
|
|
|
"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 (
|
2026-05-29 10:07:09 +02:00
|
|
|
<div className="relative min-h-screen overflow-hidden bg-gradient-to-br from-[#F5F9F8] via-[#F9FAFB] to-[#ECFDF5]">
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
className="pointer-events-none absolute -top-32 -left-32 h-96 w-96 rounded-full bg-[#0F766E]/15 blur-3xl"
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
className="pointer-events-none absolute -bottom-40 -right-32 h-[28rem] w-[28rem] rounded-full bg-emerald-200/40 blur-3xl"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div className="relative flex min-h-screen items-center justify-center px-4 py-12 sm:px-6">
|
|
|
|
|
{!otpStepChecked ? (
|
|
|
|
|
<div className="h-10 w-10 animate-spin rounded-full border-2 border-[#0F766E] border-t-transparent" />
|
|
|
|
|
) : (
|
|
|
|
|
<div className="w-full max-w-sm">
|
|
|
|
|
<div className="mb-8 flex flex-col items-center text-center">
|
|
|
|
|
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#0F766E] to-[#0d9488] text-xl font-bold text-white shadow-lg shadow-[#0F766E]/25 ring-1 ring-white/40">
|
|
|
|
|
HH
|
|
|
|
|
</div>
|
|
|
|
|
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Hellth Hub</h1>
|
|
|
|
|
<p className="mt-1.5 text-sm text-gray-500">
|
|
|
|
|
Health-Tracking fuer dein Home-Lab
|
|
|
|
|
</p>
|
2026-05-27 22:15:16 +02:00
|
|
|
</div>
|
|
|
|
|
|
2026-05-29 10:07:09 +02:00
|
|
|
<div className="rounded-2xl border border-gray-100 bg-white/90 p-6 shadow-xl shadow-gray-900/5 backdrop-blur sm:p-7">
|
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
|
|
|
{error && (
|
|
|
|
|
<div
|
|
|
|
|
role="alert"
|
|
|
|
|
className="flex items-start gap-2 rounded-lg border border-red-100 bg-red-50 px-3 py-2.5 text-sm text-red-700"
|
|
|
|
|
>
|
|
|
|
|
<span aria-hidden="true" className="mt-0.5 font-bold text-red-500">
|
|
|
|
|
!
|
|
|
|
|
</span>
|
|
|
|
|
<span>{error}</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label htmlFor="login-email" className="mb-1.5 block text-sm font-medium text-gray-700">
|
|
|
|
|
E-Mail
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
id="login-email"
|
|
|
|
|
type="email"
|
|
|
|
|
required
|
|
|
|
|
autoComplete="username webauthn"
|
|
|
|
|
value={email}
|
|
|
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
|
|
|
placeholder="du@example.com"
|
|
|
|
|
className="w-full rounded-lg border border-gray-200 bg-white px-3.5 py-2.5 text-sm text-gray-900 placeholder-gray-400 transition-shadow focus:border-[#0F766E] focus:outline-none focus:ring-2 focus:ring-[#0F766E]/20"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label htmlFor="login-password" className="mb-1.5 block text-sm font-medium text-gray-700">
|
|
|
|
|
Passwort
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
id="login-password"
|
|
|
|
|
type="password"
|
|
|
|
|
required
|
|
|
|
|
autoComplete="current-password"
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
|
|
|
placeholder="••••••••••••"
|
|
|
|
|
className="w-full rounded-lg border border-gray-200 bg-white px-3.5 py-2.5 text-sm text-gray-900 placeholder-gray-400 transition-shadow focus:border-[#0F766E] focus:outline-none focus:ring-2 focus:ring-[#0F766E]/20"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-05-27 22:15:16 +02:00
|
|
|
|
2026-05-29 10:07:09 +02:00
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
disabled={loading}
|
|
|
|
|
className="mt-1 inline-flex w-full items-center justify-center gap-2 rounded-lg bg-[#0F766E] py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-[#115E59] disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
{loading ? (
|
|
|
|
|
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white/40 border-t-white" />
|
|
|
|
|
) : null}
|
|
|
|
|
{loading ? "Anmelden..." : "Anmelden"}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<div className="relative pt-1">
|
|
|
|
|
<div aria-hidden="true" className="absolute inset-0 flex items-center">
|
|
|
|
|
<div className="w-full border-t border-gray-200" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="relative flex justify-center">
|
|
|
|
|
<span className="bg-white px-3 text-xs uppercase tracking-wider text-gray-400">
|
|
|
|
|
oder
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handlePasskeyLogin}
|
|
|
|
|
disabled={loading}
|
|
|
|
|
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white py-2.5 text-sm font-semibold text-gray-800 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<svg
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
strokeWidth="1.8"
|
|
|
|
|
className="h-4 w-4 text-[#0F766E]"
|
|
|
|
|
>
|
|
|
|
|
<path
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
d="M15.75 5.25a3 3 0 11-6 0 3 3 0 016 0zM4.5 19.5l4.5-7.5h6l4.5 7.5"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
Mit Passkey anmelden
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p className="mt-6 text-center text-xs text-gray-400">
|
|
|
|
|
Geschuetzt durch 2FA, Passkeys und verschluesselte Verbindungen.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-05-27 22:15:16 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|