Files
webshop/shop/components/inactivity-tracker.tsx
DanielS b978a6bc64
All checks were successful
Staging Build / build (push) Successful in 2m59s
feat(auth): add 10-minute inactivity auto-logout
2026-07-28 11:50:27 +02:00

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