hellth-hub/apps/admin/app/login/page.tsx
Sebastian Mayer e42a8109bf
Some checks are pending
CI / Lint, Typecheck, Test, Build (push) Waiting to run
Feature: Settings-Tabs, Login-Redesign und Security-Tests
- Einstellungen-Seite in fokussierte Komponenten aufgeteilt
  (settings-tabs, status-banner, section-card)
- Login-Seite ueberarbeitet
- Neue security.test.ts fuer 2FA-Verlust-/Recovery-Logik
- health.test.ts erweitert
- next-env.d.ts auf Next.js dev-types-Pfad aktualisiert

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:07:27 +02:00

323 lines
11 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="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>
</div>
<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>
<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>
)}
</div>
</div>
);
}