diff --git a/shop/components/inactivity-tracker.tsx b/shop/components/inactivity-tracker.tsx index a7f0bb0..c9b902b 100644 --- a/shop/components/inactivity-tracker.tsx +++ b/shop/components/inactivity-tracker.tsx @@ -1,19 +1,40 @@ "use client"; -import { useEffect } from "react"; +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(Date.now()); + const countdownIntervalRef = useRef(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; - // Browser Client inline erstellen const supabase = createBrowserClient( resolveSupabaseUrl(supabaseUrl)!, supabaseAnonKey!, @@ -24,45 +45,216 @@ export function InactivityTracker() { } ); + // 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(() => { - // Regelmäßige Überprüfung der Session-Gültigkeit (z.B. wegen Login auf anderem Browser/Gerät) + // 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) return; // Nicht eingeloggt, keine Prüfung nötig + if (!session) { + setIsLoggedIn(false); + return; + } + setIsLoggedIn(true); - // Prüfen, ob die Session auf dem Server noch gültig ist (z.B. wegen Login auf anderem Browser) + // Prüfen, ob Session auf Server noch gültig ist const { data: { user }, error } = await supabase.auth.getUser(); if (error) { - // Nur bei echten Auth-Fehlern (z.B. ungültiges Token / Session gelöscht) abmelden. - // Netzwerkfehler wie "Failed to fetch" haben keinen HTTP-Status (error.status ist undefined). const isAuthError = error.status === 400 || error.status === 401 || error.status === 403; if (isAuthError) { - try { - await signOut(); - } catch (e) { - console.error("Fehler beim Server-Signout nach Session-Verlust:", e); - } - await supabase.auth.signOut(); - router.push("/auth/login?message=concurrent"); - router.refresh(); + handleLogout("concurrent"); + return; } } else if (!user) { - // Keine Fehlermeldung, aber auch kein User-Objekt (Sitzung abgelaufen/gelöscht) - try { - await signOut(); - } catch (e) { - console.error("Fehler beim Server-Signout nach Session-Verlust:", e); - } - await supabase.auth.signOut(); - router.push("/auth/login?message=concurrent"); - router.refresh(); + handleLogout("concurrent"); + return; } - }, 5000); // Prüfung alle 5 Sekunden + + // 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, router]); + }, [supabase, handleLogout, showModal, getLastActivityFromCookie]); - return null; + // 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 ( + { if (!open) handleStayLoggedIn(); }}> + + +
+ +
+ + Noch da? + + + Sie sind seit längerem inaktiv. Aus Sicherheitsgründen werden Sie gleich abgemeldet. + +
+ + {/* Radialer SVG-Timer */} +
+ + {/* Hintergrundkreis */} + + {/* Countdown-Fortschrittskreis */} + + + {/* Numerische Restzeit in Sekunden */} +
+ + {countdown}s + + + Verbleibend + +
+
+ + + + +
+
+ ); } + diff --git a/shop/lib/supabase/proxy.ts b/shop/lib/supabase/proxy.ts index 50f87d3..63d3037 100644 --- a/shop/lib/supabase/proxy.ts +++ b/shop/lib/supabase/proxy.ts @@ -119,8 +119,34 @@ export async function updateSession(request: NextRequest) { // the cookies! // 4. Finally: // return myNewResponse - // If this is not done, you may be causing the browser and server to go out - // of sync and terminate the user's session prematurely! + // Inaktivitäts-Prüfung serverseitig (10 Minuten = 600.000 ms) + const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; + + if (user?.sub && !request.nextUrl.pathname.startsWith("/auth")) { + const lastActivityCookie = request.cookies.get("webshop-last-activity"); + const now = Date.now(); + + if (lastActivityCookie?.value) { + const lastActivityTime = parseInt(lastActivityCookie.value, 10); + if (!isNaN(lastActivityTime) && now - lastActivityTime > INACTIVITY_TIMEOUT_MS) { + // Länger als 10 Minuten inaktiv -> Auf dem Server sofort abmelden + const url = request.nextUrl.clone(); + url.pathname = "/auth/login"; + url.searchParams.set("message", "inactivity"); + const response = NextResponse.redirect(url); + response.cookies.delete("webshop-auth-token"); + response.cookies.delete("webshop-last-activity"); + return response; + } + } + + // Aktuellen Zeitstempel in Cookie schreiben + supabaseResponse.cookies.set("webshop-last-activity", now.toString(), { + path: "/", + sameSite: "lax", + httpOnly: false, // Für Client-Zugriff lesbar/schreibbar + }); + } return supabaseResponse; }