feat(auth): add 10-minute inactivity auto-logout
All checks were successful
Staging Build / build (push) Successful in 2m59s
All checks were successful
Staging Build / build (push) Successful in 2m59s
This commit is contained in:
@@ -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<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;
|
||||
|
||||
// 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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user