261 lines
8.4 KiB
TypeScript
261 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useRef, useCallback } from "react";
|
|
import { createBrowserClient } from "@supabase/ssr";
|
|
import { useRouter } from "next/navigation";
|
|
import { signOut } from "@/lib/actions/auth";
|
|
import { resolveSupabaseUrl } from "@/lib/utils";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Clock } from "lucide-react";
|
|
|
|
// Inaktivitäts-Schwellenwert (10 Minuten = 600.000 ms)
|
|
const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
// Countdown-Dauer vor automatischem Logout (10 Sekunden)
|
|
const COUNTDOWN_SECONDS = 10;
|
|
|
|
export function InactivityTracker() {
|
|
const router = useRouter();
|
|
|
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [countdown, setCountdown] = useState(COUNTDOWN_SECONDS);
|
|
|
|
const lastActivityRef = useRef<number>(Date.now());
|
|
const countdownIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
// Supabase URL & Anon Key auslesen (für Browser-Client)
|
|
const supabaseUrl = process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
|
|
|
const supabase = createBrowserClient(
|
|
resolveSupabaseUrl(supabaseUrl)!,
|
|
supabaseAnonKey!,
|
|
{
|
|
cookieOptions: {
|
|
name: "webshop-auth-token",
|
|
},
|
|
}
|
|
);
|
|
|
|
// Cookie-Hilfsfunktionen für Aktivitätssynchronisation mit dem Server
|
|
const updateLastActivityCookie = useCallback(() => {
|
|
const now = Date.now();
|
|
lastActivityRef.current = now;
|
|
document.cookie = `webshop-last-activity=${now}; path=/; SameSite=Lax`;
|
|
}, []);
|
|
|
|
const getLastActivityFromCookie = useCallback((): number => {
|
|
const match = document.cookie.match(/(?:^|; )webshop-last-activity=([^;]*)/);
|
|
if (match && match[1]) {
|
|
const val = parseInt(match[1], 10);
|
|
if (!isNaN(val)) return val;
|
|
}
|
|
return lastActivityRef.current;
|
|
}, []);
|
|
|
|
const handleLogout = useCallback(async (reason = "inactivity") => {
|
|
if (countdownIntervalRef.current) {
|
|
clearInterval(countdownIntervalRef.current);
|
|
}
|
|
setShowModal(false);
|
|
document.cookie = "webshop-last-activity=; path=/; max-age=0";
|
|
try {
|
|
await signOut();
|
|
} catch (e) {
|
|
console.error("Fehler beim Server-Signout:", e);
|
|
}
|
|
await supabase.auth.signOut();
|
|
router.push(`/auth/login?message=${reason}`);
|
|
router.refresh();
|
|
}, [supabase, router]);
|
|
|
|
// Session-Prüfung & Inaktivitäts-Überwachung
|
|
useEffect(() => {
|
|
// 1. Session prüfen
|
|
const checkSession = async () => {
|
|
const { data: { session } } = await supabase.auth.getSession();
|
|
setIsLoggedIn(!!session);
|
|
};
|
|
checkSession();
|
|
|
|
// 2. Regelmäßige Intervall-Prüfung (alle 5s)
|
|
const interval = setInterval(async () => {
|
|
const { data: { session } } = await supabase.auth.getSession();
|
|
if (!session) {
|
|
setIsLoggedIn(false);
|
|
return;
|
|
}
|
|
setIsLoggedIn(true);
|
|
|
|
// Prüfen, ob Session auf Server noch gültig ist
|
|
const { data: { user }, error } = await supabase.auth.getUser();
|
|
if (error) {
|
|
const isAuthError = error.status === 400 || error.status === 401 || error.status === 403;
|
|
if (isAuthError) {
|
|
handleLogout("concurrent");
|
|
return;
|
|
}
|
|
} else if (!user) {
|
|
handleLogout("concurrent");
|
|
return;
|
|
}
|
|
|
|
// Inaktivitätszeitraum (Client + Cookie-Synchronisation) prüfen
|
|
const lastActive = getLastActivityFromCookie();
|
|
const now = Date.now();
|
|
if (!showModal && now - lastActive >= INACTIVITY_TIMEOUT_MS) {
|
|
setShowModal(true);
|
|
setCountdown(COUNTDOWN_SECONDS);
|
|
}
|
|
}, 5000);
|
|
|
|
return () => {
|
|
clearInterval(interval);
|
|
};
|
|
}, [supabase, handleLogout, showModal, getLastActivityFromCookie]);
|
|
|
|
// Aktivitätsevents registrieren
|
|
useEffect(() => {
|
|
if (!isLoggedIn) return;
|
|
|
|
let lastEventTime = 0;
|
|
const handleUserActivity = () => {
|
|
const now = Date.now();
|
|
// Throttling: max. 1x pro Sekunde aktualisieren
|
|
if (now - lastEventTime > 1000) {
|
|
lastEventTime = now;
|
|
// Wenn Modal bereits offen ist, Aktivität nicht im Hintergrund zurücksetzen
|
|
if (!showModal) {
|
|
updateLastActivityCookie();
|
|
}
|
|
}
|
|
};
|
|
|
|
const events = ["mousemove", "keydown", "click", "scroll", "touchstart"];
|
|
events.forEach((event) => {
|
|
window.addEventListener(event, handleUserActivity, { passive: true });
|
|
});
|
|
|
|
return () => {
|
|
events.forEach((event) => {
|
|
window.removeEventListener(event, handleUserActivity);
|
|
});
|
|
};
|
|
}, [isLoggedIn, showModal, updateLastActivityCookie]);
|
|
|
|
// Countdown-Timer verwalten, wenn Modal geöffnet wird
|
|
useEffect(() => {
|
|
if (!showModal) {
|
|
if (countdownIntervalRef.current) {
|
|
clearInterval(countdownIntervalRef.current);
|
|
}
|
|
return;
|
|
}
|
|
|
|
countdownIntervalRef.current = setInterval(() => {
|
|
setCountdown((prev) => {
|
|
if (prev <= 1) {
|
|
clearInterval(countdownIntervalRef.current!);
|
|
handleLogout("inactivity");
|
|
return 0;
|
|
}
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
|
|
return () => {
|
|
if (countdownIntervalRef.current) {
|
|
clearInterval(countdownIntervalRef.current);
|
|
}
|
|
};
|
|
}, [showModal, handleLogout]);
|
|
|
|
// Inaktivität zurücksetzen / Modal schließen
|
|
const handleStayLoggedIn = () => {
|
|
updateLastActivityCookie();
|
|
setShowModal(false);
|
|
if (countdownIntervalRef.current) {
|
|
clearInterval(countdownIntervalRef.current);
|
|
}
|
|
};
|
|
|
|
if (!isLoggedIn) return null;
|
|
|
|
// Werte für den radialen SVG-Timer (Kreisumfang)
|
|
const radius = 40;
|
|
const circumference = 2 * Math.PI * radius;
|
|
const strokeDashoffset = circumference - (countdown / COUNTDOWN_SECONDS) * circumference;
|
|
|
|
return (
|
|
<Dialog open={showModal} onOpenChange={(open) => { if (!open) handleStayLoggedIn(); }}>
|
|
<DialogContent showCloseButton={false} className="sm:max-w-md text-center flex flex-col items-center gap-6 p-8">
|
|
<DialogHeader className="items-center text-center">
|
|
<div className="w-12 h-12 rounded-full bg-amber-500/10 text-amber-500 flex items-center justify-center mb-2">
|
|
<Clock className="w-6 h-6 animate-pulse" />
|
|
</div>
|
|
<DialogTitle className="text-xl font-bold text-foreground">
|
|
Noch da?
|
|
</DialogTitle>
|
|
<DialogDescription className="text-muted-foreground text-sm max-w-xs mt-1">
|
|
Sie sind seit längerem inaktiv. Aus Sicherheitsgründen werden Sie gleich abgemeldet.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{/* Radialer SVG-Timer */}
|
|
<div className="relative flex items-center justify-center my-2">
|
|
<svg className="w-32 h-32 transform -rotate-90" viewBox="0 0 100 100">
|
|
{/* Hintergrundkreis */}
|
|
<circle
|
|
cx="50"
|
|
cy="50"
|
|
r={radius}
|
|
className="stroke-muted/20"
|
|
strokeWidth="8"
|
|
fill="transparent"
|
|
/>
|
|
{/* Countdown-Fortschrittskreis */}
|
|
<circle
|
|
cx="50"
|
|
cy="50"
|
|
r={radius}
|
|
className="stroke-amber-500 transition-all duration-1000 ease-linear"
|
|
strokeWidth="8"
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={strokeDashoffset}
|
|
strokeLinecap="round"
|
|
fill="transparent"
|
|
/>
|
|
</svg>
|
|
{/* Numerische Restzeit in Sekunden */}
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
<span className="text-3xl font-extrabold text-foreground tracking-tight">
|
|
{countdown}s
|
|
</span>
|
|
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold mt-0.5">
|
|
Verbleibend
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter className="w-full sm:justify-center">
|
|
<Button
|
|
onClick={handleStayLoggedIn}
|
|
className="w-full sm:w-auto px-8 bg-amber-500 hover:bg-amber-600 text-black font-semibold shadow-lg shadow-amber-500/20"
|
|
>
|
|
Ich bin noch da
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|