feat(auth): add 10-minute inactivity auto-logout
All checks were successful
Staging Build / build (push) Successful in 2m59s

This commit is contained in:
DanielS
2026-07-28 11:50:27 +02:00
parent a43d598f54
commit b978a6bc64
2 changed files with 247 additions and 29 deletions

View File

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

View File

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