349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import { signIn, verifyDevice2FA, resend2FACode } from "@/lib/actions/auth";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { useState, useEffect, useRef, useCallback } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
|
|
function getDeviceHash(): string {
|
|
const key = "caspos-device-id";
|
|
let id = localStorage.getItem(key);
|
|
if (!id) {
|
|
id = crypto.randomUUID();
|
|
localStorage.setItem(key, id);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
export function LoginForm({
|
|
className,
|
|
...props
|
|
}: React.ComponentPropsWithoutRef<"div">) {
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [messageParam, setMessageParam] = useState<string | null>(null);
|
|
const [errorParam, setErrorParam] = useState<string | null>(null);
|
|
const router = useRouter();
|
|
|
|
// 2FA state
|
|
const [show2FA, setShow2FA] = useState(false);
|
|
const [otpDigits, setOtpDigits] = useState<string[]>(["", "", "", "", "", ""]);
|
|
const [userId, setUserId] = useState<string | null>(null);
|
|
const [userRole, setUserRole] = useState<string | null>(null);
|
|
const [resendCooldown, setResendCooldown] = useState(0);
|
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
|
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
setMessageParam(params.get('message'));
|
|
setErrorParam(params.get('error'));
|
|
}, []);
|
|
|
|
// Resend cooldown timer
|
|
useEffect(() => {
|
|
if (resendCooldown <= 0) return;
|
|
const timer = setTimeout(() => setResendCooldown(c => c - 1), 1000);
|
|
return () => clearTimeout(timer);
|
|
}, [resendCooldown]);
|
|
|
|
const navigateAfterLogin = useCallback((role: string) => {
|
|
const nextParam = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('next') : null;
|
|
if (nextParam) {
|
|
router.push(nextParam);
|
|
} else if (role === "admin") {
|
|
router.push("/admin/einstellungen");
|
|
} else {
|
|
router.push("/my-customers");
|
|
}
|
|
}, [router]);
|
|
|
|
const handleLogin = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const deviceHash = getDeviceHash();
|
|
const res = await signIn(email, password, deviceHash);
|
|
|
|
if (!res.success) {
|
|
setError(res.error || "Ein Fehler ist aufgetreten.");
|
|
return;
|
|
}
|
|
|
|
if (res.requires2FA) {
|
|
setUserId(res.userId || null);
|
|
setUserRole(res.role || null);
|
|
setShow2FA(true);
|
|
setResendCooldown(60);
|
|
setOtpDigits(["", "", "", "", "", ""]);
|
|
setTimeout(() => inputRefs.current[0]?.focus(), 300);
|
|
return;
|
|
}
|
|
|
|
navigateAfterLogin(res.role || "partner");
|
|
} catch (error: unknown) {
|
|
setError(error instanceof Error ? error.message : "Ein Fehler ist aufgetreten.");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleOtpChange = (index: number, value: string) => {
|
|
if (!/^\d*$/.test(value)) return;
|
|
const newDigits = [...otpDigits];
|
|
newDigits[index] = value.slice(-1);
|
|
setOtpDigits(newDigits);
|
|
|
|
// Auto-advance
|
|
if (value && index < 5) {
|
|
inputRefs.current[index + 1]?.focus();
|
|
}
|
|
|
|
// Auto-submit when all filled
|
|
if (newDigits.every(d => d !== "") && newDigits.join("").length === 6) {
|
|
handleVerify2FA(newDigits.join(""));
|
|
}
|
|
};
|
|
|
|
const handleOtpKeyDown = (index: number, e: React.KeyboardEvent) => {
|
|
if (e.key === "Backspace" && !otpDigits[index] && index > 0) {
|
|
inputRefs.current[index - 1]?.focus();
|
|
}
|
|
};
|
|
|
|
const handleOtpPaste = (e: React.ClipboardEvent) => {
|
|
e.preventDefault();
|
|
const paste = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
|
|
if (paste.length === 6) {
|
|
const newDigits = paste.split("");
|
|
setOtpDigits(newDigits);
|
|
inputRefs.current[5]?.focus();
|
|
handleVerify2FA(paste);
|
|
}
|
|
};
|
|
|
|
const handleVerify2FA = async (code: string) => {
|
|
if (!userId) return;
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const deviceHash = getDeviceHash();
|
|
const res = await verifyDevice2FA(userId, code, deviceHash);
|
|
|
|
if (!res.success) {
|
|
setError(res.error || "Ungültiger Code.");
|
|
setOtpDigits(["", "", "", "", "", ""]);
|
|
inputRefs.current[0]?.focus();
|
|
return;
|
|
}
|
|
|
|
navigateAfterLogin(res.role || userRole || "partner");
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : "Fehler bei der Verifizierung.");
|
|
setOtpDigits(["", "", "", "", "", ""]);
|
|
inputRefs.current[0]?.focus();
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleResend = async () => {
|
|
if (!userId || resendCooldown > 0) return;
|
|
setError(null);
|
|
const deviceHash = getDeviceHash();
|
|
const res = await resend2FACode(userId, deviceHash);
|
|
if (res.success) {
|
|
setResendCooldown(60);
|
|
} else {
|
|
setError(res.error || "Fehler beim erneuten Senden.");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
|
<Card className="overflow-hidden">
|
|
<AnimatePresence mode="wait">
|
|
{!show2FA ? (
|
|
<motion.div
|
|
key="login"
|
|
initial={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -40, filter: "blur(4px)" }}
|
|
transition={{ duration: 0.35, ease: "easeInOut" }}
|
|
>
|
|
<CardHeader>
|
|
<CardTitle className="text-2xl">Anmelden</CardTitle>
|
|
<CardDescription>
|
|
Geben Sie Ihre E-Mail-Adresse und Ihr Passwort ein, um sich anzumelden.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleLogin}>
|
|
<div className="flex flex-col gap-6">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="email">E-Mail</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
placeholder="name@beispiel.de"
|
|
required
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<div className="flex items-center">
|
|
<Label htmlFor="password">Passwort</Label>
|
|
<Link
|
|
href="/auth/forgot-password"
|
|
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
|
>
|
|
Passwort vergessen?
|
|
</Link>
|
|
</div>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
required
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
</div>
|
|
{errorParam === "gesperrt" && (
|
|
<p className="text-sm text-red-500 bg-red-500/10 border border-red-500/20 p-3 rounded-lg text-center font-medium">
|
|
🚫 Ihr Konto wurde gesperrt. Bitte wenden Sie sich an den Administrator.
|
|
</p>
|
|
)}
|
|
{messageParam === "concurrent" && (
|
|
<p className="text-sm text-amber-500 bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-center font-medium">
|
|
Sie wurden abgemeldet, da Sie sich an einem anderen Gerät angemeldet haben.
|
|
</p>
|
|
)}
|
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
{isLoading ? "Wird angemeldet..." : "Anmelden"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</motion.div>
|
|
) : (
|
|
<motion.div
|
|
key="2fa"
|
|
initial={{ opacity: 0, x: 40, filter: "blur(4px)" }}
|
|
animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
|
|
exit={{ opacity: 0, x: -40 }}
|
|
transition={{ duration: 0.35, ease: "easeInOut" }}
|
|
>
|
|
<CardHeader className="text-center">
|
|
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-blue-600 dark:text-blue-400">
|
|
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
</svg>
|
|
</div>
|
|
<CardTitle className="text-2xl">Sicherheitscode</CardTitle>
|
|
<CardDescription className="text-sm mt-1">
|
|
Wir haben einen 6-stelligen Code an<br />
|
|
<span className="font-semibold text-foreground">{email}</span><br />
|
|
gesendet.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-col gap-6">
|
|
{/* OTP Input */}
|
|
<div className="flex justify-center gap-2" onPaste={handleOtpPaste}>
|
|
{otpDigits.map((digit, i) => (
|
|
<input
|
|
key={i}
|
|
ref={(el) => { inputRefs.current[i] = el; }}
|
|
type="text"
|
|
inputMode="numeric"
|
|
maxLength={1}
|
|
value={digit}
|
|
onChange={(e) => handleOtpChange(i, e.target.value)}
|
|
onKeyDown={(e) => handleOtpKeyDown(i, e)}
|
|
className={cn(
|
|
"h-14 w-11 rounded-xl border-2 bg-muted/50 text-center text-xl font-bold transition-all duration-200",
|
|
"focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 focus:outline-none",
|
|
"hover:border-muted-foreground/40",
|
|
digit ? "border-blue-400 bg-blue-50 dark:bg-blue-950/30" : "border-muted-foreground/20"
|
|
)}
|
|
id={`otp-${i}`}
|
|
autoComplete="one-time-code"
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{error && (
|
|
<motion.p
|
|
initial={{ opacity: 0, y: -8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="text-sm text-red-500 text-center bg-red-500/10 border border-red-500/20 p-3 rounded-lg font-medium"
|
|
>
|
|
{error}
|
|
</motion.p>
|
|
)}
|
|
|
|
<Button
|
|
type="button"
|
|
className="w-full"
|
|
disabled={isLoading || otpDigits.some(d => !d)}
|
|
onClick={() => handleVerify2FA(otpDigits.join(""))}
|
|
>
|
|
{isLoading ? "Wird überprüft..." : "Bestätigen"}
|
|
</Button>
|
|
|
|
<div className="text-center text-sm text-muted-foreground">
|
|
Code nicht erhalten?{" "}
|
|
<button
|
|
type="button"
|
|
onClick={handleResend}
|
|
disabled={resendCooldown > 0}
|
|
className={cn(
|
|
"font-medium underline-offset-4 hover:underline transition-colors",
|
|
resendCooldown > 0
|
|
? "text-muted-foreground/50 cursor-not-allowed"
|
|
: "text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
|
)}
|
|
>
|
|
{resendCooldown > 0 ? `Erneut senden (${resendCooldown}s)` : "Erneut senden"}
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShow2FA(false);
|
|
setError(null);
|
|
setOtpDigits(["", "", "", "", "", ""]);
|
|
}}
|
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors text-center"
|
|
>
|
|
← Zurück zur Anmeldung
|
|
</button>
|
|
</div>
|
|
</CardContent>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|