189 lines
6.6 KiB
TypeScript
189 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>
|
||
|
|
);
|
||
|
|
}
|