hellth-hub/apps/admin/app/login/page.tsx

273 lines
8.5 KiB
TypeScript
Raw Normal View History

"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>
);
}