session timeout
This commit is contained in:
@@ -64,6 +64,43 @@ This project is built with:
|
|||||||
|
|
||||||
Simply open [Lovable](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and click on Share -> Publish.
|
Simply open [Lovable](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and click on Share -> Publish.
|
||||||
|
|
||||||
|
## Session Timeout Behavior
|
||||||
|
|
||||||
|
This application implements automatic session timeout to align with security best practices (OWASP Session Management Cheat Sheet, NIST 800-63B Section 7.1).
|
||||||
|
|
||||||
|
### Backend Configuration
|
||||||
|
|
||||||
|
The backend uses a sliding window session model with two independent timeouts:
|
||||||
|
|
||||||
|
| Timeout | Default | Description |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| Idle | 15 minutes | If no authenticated request is made within this window, the session expires |
|
||||||
|
| Absolute | 8 hours | Hard cap from session creation. Activity cannot extend past this point |
|
||||||
|
|
||||||
|
Both are configurable via environment variables: `SESSION_IDLE_TIMEOUT` and `SESSION_ABSOLUTE_TIMEOUT` (values in seconds).
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
- **Sliding Window**: Every authenticated request automatically resets the idle clock
|
||||||
|
- **Active User**: Session keeps extending up to the 8-hour absolute maximum
|
||||||
|
- **Idle User**: After 15 minutes of inactivity, the session expires and the next request returns 401
|
||||||
|
- **Heartbeat**: The frontend sends a periodic `GET /api/v1/auth/me` every 5 minutes to keep sessions alive during passive activities like reading long pages
|
||||||
|
|
||||||
|
### Frontend UX
|
||||||
|
|
||||||
|
- **Warning Dialog**: When the user is within 3 minutes of session expiry, a warning dialog appears with a countdown timer
|
||||||
|
- **Extend Session**: Users can click "Keep Me Signed In" to refresh the session via `POST /api/v1/auth/sessions/refresh`
|
||||||
|
- **Graceful Expiry**: When a session expires, the user is redirected to the login page with a gentle message: "Your session has expired due to inactivity"
|
||||||
|
- **No Hard Logouts**: The frontend never forcefully logs out an active user; expiry only occurs after API confirmation (401 response)
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `POST /api/v1/auth/sessions/refresh` | Explicitly refresh session (extends idle timeout) |
|
||||||
|
| `GET /api/v1/auth/sessions` | List all active sessions for the user |
|
||||||
|
| `DELETE /api/v1/auth/sessions/:id` | Revoke a specific session |
|
||||||
|
|
||||||
## Can I connect a custom domain to my Lovable project?
|
## Can I connect a custom domain to my Lovable project?
|
||||||
|
|
||||||
Yes, you can!
|
Yes, you can!
|
||||||
|
|||||||
+24
@@ -104,6 +104,8 @@ function ScrollToTop() {
|
|||||||
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
|
import { AuthProvider, useAuth } from "@/contexts/AuthContext";
|
||||||
import { OrgProvider } from "@/contexts/OrgContext";
|
import { OrgProvider } from "@/contexts/OrgContext";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useSessionTimeout } from "@/hooks/useSessionTimeout";
|
||||||
|
import { SessionWarning } from "@/components/auth/SessionWarning";
|
||||||
|
|
||||||
/** Redirects already-authenticated users away from guest-only pages (e.g. /login). */
|
/** Redirects already-authenticated users away from guest-only pages (e.g. /login). */
|
||||||
function GuestRoute({ children }: { children: React.ReactNode }) {
|
function GuestRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -148,9 +150,31 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles session timeout detection, warning dialog, and heartbeat.
|
||||||
|
* Rendered inside AuthProvider so it can access auth state.
|
||||||
|
*/
|
||||||
|
function SessionTimeoutManager() {
|
||||||
|
const { isAuthenticated, logout } = useAuth();
|
||||||
|
const { isWarningOpen, countdownSeconds, extendSession } = useSessionTimeout(
|
||||||
|
isAuthenticated,
|
||||||
|
logout
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionWarning
|
||||||
|
open={isWarningOpen}
|
||||||
|
countdownSeconds={countdownSeconds}
|
||||||
|
onExtend={extendSession}
|
||||||
|
onLogout={() => logout()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<SessionTimeoutManager />
|
||||||
<OrgProvider>
|
<OrgProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Marketing pages */}
|
{/* Marketing pages */}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SessionWarningProps {
|
||||||
|
open: boolean;
|
||||||
|
countdownSeconds: number;
|
||||||
|
onExtend: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warning dialog shown before automatic session timeout.
|
||||||
|
* Gives the user an opportunity to extend their session.
|
||||||
|
*/
|
||||||
|
export function SessionWarning({
|
||||||
|
open,
|
||||||
|
countdownSeconds,
|
||||||
|
onExtend,
|
||||||
|
onLogout,
|
||||||
|
}: SessionWarningProps) {
|
||||||
|
const minutes = Math.floor(countdownSeconds / 60);
|
||||||
|
const seconds = countdownSeconds % 60;
|
||||||
|
const timeDisplay = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={() => {}}>
|
||||||
|
<DialogContent className="sm:max-w-md" onInteractOutside={(e) => e.preventDefault()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||||
|
Session Expiring Soon
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Your session will expire in{' '}
|
||||||
|
<span className="font-mono font-semibold text-foreground">
|
||||||
|
{timeDisplay}
|
||||||
|
</span>{' '}
|
||||||
|
due to inactivity.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-2 text-sm text-muted-foreground">
|
||||||
|
If you do not extend your session, you will be logged out automatically
|
||||||
|
and any unsaved work may be lost.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button variant="outline" onClick={onLogout}>
|
||||||
|
Log Out Now
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onExtend} autoFocus>
|
||||||
|
Keep Me Signed In
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,14 +21,18 @@ interface AuthContextType {
|
|||||||
canViewSystemLogs: boolean;
|
canViewSystemLogs: boolean;
|
||||||
mfaCompliance: MfaComplianceSummary | null;
|
mfaCompliance: MfaComplianceSummary | null;
|
||||||
requiresMfaEnrollment: boolean;
|
requiresMfaEnrollment: boolean;
|
||||||
|
/** True when the previous session ended due to timeout/expiry rather than manual logout. */
|
||||||
|
sessionExpired: boolean;
|
||||||
login: (email: string, password: string, rememberMe?: boolean, skipNavigate?: boolean) => Promise<LoginResult>;
|
login: (email: string, password: string, rememberMe?: boolean, skipNavigate?: boolean) => Promise<LoginResult>;
|
||||||
verifyTotp: (code: string, isBackupCode?: boolean, skipNavigate?: boolean) => Promise<void>;
|
verifyTotp: (code: string, isBackupCode?: boolean, skipNavigate?: boolean) => Promise<void>;
|
||||||
verifyWebAuthn: () => Promise<void>;
|
verifyWebAuthn: () => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: (expired?: boolean) => Promise<void>;
|
||||||
refreshUser: () => Promise<void>;
|
refreshUser: () => Promise<void>;
|
||||||
refreshCompliance: () => Promise<void>;
|
refreshCompliance: () => Promise<void>;
|
||||||
/** Re-check org membership & admin status. Exposed so post-setup pages can update the context. */
|
/** Re-check org membership & admin status. Exposed so post-setup pages can update the context. */
|
||||||
checkOrgAdmin: () => Promise<void>;
|
checkOrgAdmin: () => Promise<void>;
|
||||||
|
/** Clear the sessionExpired flag (e.g., after showing the message to the user). */
|
||||||
|
clearSessionExpired: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | null>(null);
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
@@ -87,6 +91,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [mfaCompliance, setMfaCompliance] = useState<MfaComplianceSummary | null>(loadMfaCompliance);
|
const [mfaCompliance, setMfaCompliance] = useState<MfaComplianceSummary | null>(loadMfaCompliance);
|
||||||
const [requiresMfaEnrollment, setRequiresMfaEnrollment] = useState(false);
|
const [requiresMfaEnrollment, setRequiresMfaEnrollment] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [sessionExpired, setSessionExpired] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Helper to check if user is admin/owner in any org
|
// Helper to check if user is admin/owner in any org
|
||||||
@@ -215,6 +220,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
if (response.user) {
|
if (response.user) {
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
|
setSessionExpired(false);
|
||||||
if (response.mfa_compliance) {
|
if (response.mfa_compliance) {
|
||||||
setMfaCompliance(response.mfa_compliance);
|
setMfaCompliance(response.mfa_compliance);
|
||||||
persistMfaCompliance(response.mfa_compliance);
|
persistMfaCompliance(response.mfa_compliance);
|
||||||
@@ -278,7 +284,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [navigate, checkOrgAdmin]);
|
}, [navigate, checkOrgAdmin]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async (expired = false) => {
|
||||||
try {
|
try {
|
||||||
await api.auth.logout();
|
await api.auth.logout();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -288,10 +294,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setMfaCompliance(null);
|
setMfaCompliance(null);
|
||||||
persistMfaCompliance(null);
|
persistMfaCompliance(null);
|
||||||
setRequiresMfaEnrollment(false);
|
setRequiresMfaEnrollment(false);
|
||||||
|
setSessionExpired(expired);
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
const clearSessionExpired = useCallback(() => {
|
||||||
|
setSessionExpired(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -303,6 +314,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
canViewSystemLogs: user?.can_view_system_logs ?? false,
|
canViewSystemLogs: user?.can_view_system_logs ?? false,
|
||||||
mfaCompliance,
|
mfaCompliance,
|
||||||
requiresMfaEnrollment,
|
requiresMfaEnrollment,
|
||||||
|
sessionExpired,
|
||||||
login,
|
login,
|
||||||
verifyTotp,
|
verifyTotp,
|
||||||
verifyWebAuthn,
|
verifyWebAuthn,
|
||||||
@@ -310,6 +322,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
refreshUser,
|
refreshUser,
|
||||||
refreshCompliance,
|
refreshCompliance,
|
||||||
checkOrgAdmin,
|
checkOrgAdmin,
|
||||||
|
clearSessionExpired,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { api, tokenManager } from '@/lib/api';
|
||||||
|
|
||||||
|
// Configuration constants (in milliseconds)
|
||||||
|
const IDLE_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
|
||||||
|
const WARNING_BEFORE_MS = 3 * 60 * 1000; // Show warning 3 minutes before timeout
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // Heartbeat every 5 minutes
|
||||||
|
|
||||||
|
interface UseSessionTimeoutReturn {
|
||||||
|
isWarningOpen: boolean;
|
||||||
|
countdownSeconds: number;
|
||||||
|
extendSession: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages session timeout behavior:
|
||||||
|
* - Detects user activity to track idle time
|
||||||
|
* - Shows a warning dialog before the session expires
|
||||||
|
* - Sends periodic heartbeats to keep the session alive during passive activity
|
||||||
|
* - Automatically logs out when the session expires
|
||||||
|
*
|
||||||
|
* The backend sliding window is authoritative; this hook provides UX around it.
|
||||||
|
*/
|
||||||
|
export function useSessionTimeout(
|
||||||
|
isAuthenticated: boolean,
|
||||||
|
onLogout: () => Promise<void>
|
||||||
|
): UseSessionTimeoutReturn {
|
||||||
|
const [isWarningOpen, setIsWarningOpen] = useState(false);
|
||||||
|
const [countdownSeconds, setCountdownSeconds] = useState(0);
|
||||||
|
|
||||||
|
// Use refs to avoid stale closures in event listeners and intervals
|
||||||
|
const lastActivityRef = useRef<number>(Date.now());
|
||||||
|
const warningShownRef = useRef<boolean>(false);
|
||||||
|
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const countdownIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const activityCheckIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const isAuthenticatedRef = useRef(isAuthenticated);
|
||||||
|
const onLogoutRef = useRef(onLogout);
|
||||||
|
|
||||||
|
// Keep refs in sync with props
|
||||||
|
useEffect(() => {
|
||||||
|
isAuthenticatedRef.current = isAuthenticated;
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onLogoutRef.current = onLogout;
|
||||||
|
}, [onLogout]);
|
||||||
|
|
||||||
|
// Update last activity timestamp
|
||||||
|
const updateActivity = useCallback(() => {
|
||||||
|
lastActivityRef.current = Date.now();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Perform logout when session expires
|
||||||
|
const performLogout = useCallback(async (reason: string) => {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log(`[SessionTimeout] Logging out: ${reason}`);
|
||||||
|
}
|
||||||
|
// Clear warning state
|
||||||
|
setIsWarningOpen(false);
|
||||||
|
warningShownRef.current = false;
|
||||||
|
// Perform logout
|
||||||
|
await onLogoutRef.current();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Extend the session by calling the refresh endpoint
|
||||||
|
const extendSession = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await api.auth.refreshSession();
|
||||||
|
// Reset activity and warning state
|
||||||
|
lastActivityRef.current = Date.now();
|
||||||
|
setIsWarningOpen(false);
|
||||||
|
warningShownRef.current = false;
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('[SessionTimeout] Session extended successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SessionTimeout] Failed to extend session:', error);
|
||||||
|
// If refresh fails, perform logout
|
||||||
|
await performLogout('refresh failed');
|
||||||
|
}
|
||||||
|
}, [performLogout]);
|
||||||
|
|
||||||
|
// Setup activity listeners and timers
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// Clean up any existing state when logged out
|
||||||
|
setIsWarningOpen(false);
|
||||||
|
warningShownRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset activity timestamp on mount/auth change
|
||||||
|
lastActivityRef.current = Date.now();
|
||||||
|
warningShownRef.current = false;
|
||||||
|
|
||||||
|
// Activity event listeners
|
||||||
|
const activityEvents = ['mousedown', 'keydown', 'touchstart', 'scroll'];
|
||||||
|
const handleActivity = () => {
|
||||||
|
lastActivityRef.current = Date.now();
|
||||||
|
|
||||||
|
// If warning is open and user becomes active, extend session automatically
|
||||||
|
if (warningShownRef.current) {
|
||||||
|
extendSession();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
activityEvents.forEach((event) => {
|
||||||
|
window.addEventListener(event, handleActivity, { passive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Heartbeat: keep session alive during passive reading
|
||||||
|
heartbeatIntervalRef.current = setInterval(() => {
|
||||||
|
if (!isAuthenticatedRef.current) return;
|
||||||
|
|
||||||
|
api.users.me().catch(async (error) => {
|
||||||
|
if (error?.code === 401) {
|
||||||
|
await performLogout('heartbeat received 401');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
|
||||||
|
// Activity check: determine when to show warning or logout
|
||||||
|
activityCheckIntervalRef.current = setInterval(() => {
|
||||||
|
if (!isAuthenticatedRef.current) return;
|
||||||
|
|
||||||
|
const idleTime = Date.now() - lastActivityRef.current;
|
||||||
|
const timeUntilTimeout = IDLE_TIMEOUT_MS - idleTime;
|
||||||
|
|
||||||
|
// If idle time exceeds timeout, logout
|
||||||
|
if (idleTime >= IDLE_TIMEOUT_MS) {
|
||||||
|
performLogout('idle timeout exceeded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show warning if we're within the warning threshold and not already shown
|
||||||
|
if (timeUntilTimeout <= WARNING_BEFORE_MS && !warningShownRef.current) {
|
||||||
|
warningShownRef.current = true;
|
||||||
|
setIsWarningOpen(true);
|
||||||
|
setCountdownSeconds(Math.ceil(timeUntilTimeout / 1000));
|
||||||
|
|
||||||
|
// Start countdown
|
||||||
|
countdownIntervalRef.current = setInterval(() => {
|
||||||
|
const remaining = IDLE_TIMEOUT_MS - (Date.now() - lastActivityRef.current);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
// Time's up - logout
|
||||||
|
if (countdownIntervalRef.current) {
|
||||||
|
clearInterval(countdownIntervalRef.current);
|
||||||
|
}
|
||||||
|
performLogout('countdown reached zero');
|
||||||
|
} else {
|
||||||
|
setCountdownSeconds(Math.ceil(remaining / 1000));
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, 5000); // Check every 5 seconds
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
activityEvents.forEach((event) => {
|
||||||
|
window.removeEventListener(event, handleActivity);
|
||||||
|
});
|
||||||
|
if (heartbeatIntervalRef.current) {
|
||||||
|
clearInterval(heartbeatIntervalRef.current);
|
||||||
|
}
|
||||||
|
if (countdownIntervalRef.current) {
|
||||||
|
clearInterval(countdownIntervalRef.current);
|
||||||
|
}
|
||||||
|
if (activityCheckIntervalRef.current) {
|
||||||
|
clearInterval(activityCheckIntervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isAuthenticated, extendSession, performLogout]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isWarningOpen,
|
||||||
|
countdownSeconds,
|
||||||
|
extendSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -100,6 +100,24 @@ export interface LoginResponse {
|
|||||||
is_first_user?: boolean;
|
is_first_user?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Session {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
user_agent: string | null;
|
||||||
|
is_current: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionsResponse {
|
||||||
|
sessions: Session[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshSessionResponse {
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TotpEnrollResponse {
|
export interface TotpEnrollResponse {
|
||||||
secret: string;
|
secret: string;
|
||||||
provisioning_uri: string;
|
provisioning_uri: string;
|
||||||
@@ -333,6 +351,14 @@ export const tokenManager = {
|
|||||||
hasValidToken: (): boolean => {
|
hasValidToken: (): boolean => {
|
||||||
return tokenManager.getToken() !== null;
|
return tokenManager.getToken() !== null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateExpiry: (expiresAt: string): void => {
|
||||||
|
localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt);
|
||||||
|
},
|
||||||
|
|
||||||
|
getExpiry: (): string | null => {
|
||||||
|
return localStorage.getItem(TOKEN_EXPIRY_KEY);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Error types that indicate the session/token is truly invalid
|
// Error types that indicate the session/token is truly invalid
|
||||||
@@ -511,6 +537,22 @@ export const api = {
|
|||||||
tokenManager.clearToken();
|
tokenManager.clearToken();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
refreshSession: async () => {
|
||||||
|
const response = await request<RefreshSessionResponse>('/auth/sessions/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
tokenManager.updateExpiry(response.expires_at);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
listSessions: () =>
|
||||||
|
request<SessionsResponse>('/auth/sessions', {}),
|
||||||
|
|
||||||
|
revokeSession: (sessionId: string) =>
|
||||||
|
request<{ message: string }>(`/auth/sessions/${encodeURIComponent(sessionId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
users: {
|
users: {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ async function completeOidcFlow(oidcSessionId: string, token: string): Promise<s
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { login, verifyTotp, refreshUser, user, isLoading: authLoading, checkOrgAdmin } = useAuth();
|
const { login, verifyTotp, refreshUser, user, isLoading: authLoading, checkOrgAdmin, sessionExpired, clearSessionExpired } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
@@ -88,6 +88,13 @@ export default function LoginPage() {
|
|||||||
.catch(() => {/* ignore — user will just land on normal login */});
|
.catch(() => {/* ignore — user will just land on normal login */});
|
||||||
}, [cliToken]);
|
}, [cliToken]);
|
||||||
|
|
||||||
|
// Clear session expired flag once the user sees the login page
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionExpired) {
|
||||||
|
clearSessionExpired();
|
||||||
|
}
|
||||||
|
}, [sessionExpired, clearSessionExpired]);
|
||||||
|
|
||||||
const finishCliFlow = useCallback((token: string) => {
|
const finishCliFlow = useCallback((token: string) => {
|
||||||
if (!cliRedirectUrl) return false;
|
if (!cliRedirectUrl) return false;
|
||||||
// cliRedirectUrl already ends with "token=" — just append the value
|
// cliRedirectUrl already ends with "token=" — just append the value
|
||||||
@@ -955,6 +962,12 @@ export default function LoginPage() {
|
|||||||
{oidcError && (
|
{oidcError && (
|
||||||
<p className="text-sm text-destructive mt-2">{oidcError}</p>
|
<p className="text-sm text-destructive mt-2">{oidcError}</p>
|
||||||
)}
|
)}
|
||||||
|
{sessionExpired && (
|
||||||
|
<div className="mt-4 p-3 rounded-md bg-amber-50 border border-amber-200 text-amber-800 text-sm flex items-start gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span>Your session has expired due to inactivity. Please sign in again to continue.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { api, tokenManager } from '../src/lib/api';
|
||||||
|
|
||||||
|
// Mock localStorage for Node.js test environment
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {};
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store[key] || null,
|
||||||
|
setItem: (key: string, value: string) => { store[key] = value; },
|
||||||
|
removeItem: (key: string) => { delete store[key]; },
|
||||||
|
clear: () => { store = {}; },
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
Object.defineProperty(global, 'localStorage', {
|
||||||
|
value: localStorageMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Session API', () => {
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refreshSession', () => {
|
||||||
|
it('calls the correct endpoint and updates token expiry', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
message: 'Session refreshed',
|
||||||
|
data: { expires_at: '2026-04-26T18:30:00Z' },
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set a token first
|
||||||
|
tokenManager.setToken('test-token', '2026-04-26T17:00:00Z');
|
||||||
|
|
||||||
|
const result = await api.auth.refreshSession();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/auth/sessions/refresh'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
Authorization: 'Bearer test-token',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result.expires_at).toBe('2026-04-26T18:30:00Z');
|
||||||
|
expect(tokenManager.getExpiry()).toBe('2026-04-26T18:30:00Z');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listSessions', () => {
|
||||||
|
it('calls the correct endpoint', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
message: 'Sessions listed',
|
||||||
|
data: {
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: 'sess-1',
|
||||||
|
created_at: '2026-04-26T10:00:00Z',
|
||||||
|
expires_at: '2026-04-26T18:00:00Z',
|
||||||
|
last_used_at: '2026-04-26T17:00:00Z',
|
||||||
|
ip_address: '127.0.0.1',
|
||||||
|
user_agent: 'Mozilla/5.0',
|
||||||
|
is_current: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
tokenManager.setToken('test-token');
|
||||||
|
|
||||||
|
const result = await api.auth.listSessions();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/auth/sessions'),
|
||||||
|
expect.objectContaining({
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result.sessions).toHaveLength(1);
|
||||||
|
expect(result.sessions[0].id).toBe('sess-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('revokeSession', () => {
|
||||||
|
it('encodes session ID and calls the correct endpoint', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
message: 'Session revoked',
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
tokenManager.setToken('test-token');
|
||||||
|
|
||||||
|
await api.auth.revokeSession('sess-abc/123');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/auth/sessions/sess-abc%2F123'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tokenManager', () => {
|
||||||
|
it('updateExpiry sets the expiry in localStorage', () => {
|
||||||
|
tokenManager.setToken('test-token');
|
||||||
|
tokenManager.updateExpiry('2026-04-26T20:00:00Z');
|
||||||
|
expect(tokenManager.getExpiry()).toBe('2026-04-26T20:00:00Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getExpiry returns null when no expiry is set', () => {
|
||||||
|
localStorage.clear();
|
||||||
|
expect(tokenManager.getExpiry()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { useSessionTimeout } from '../src/hooks/useSessionTimeout';
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = (() => {
|
||||||
|
let store: Record<string, string> = {};
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => store[key] || null,
|
||||||
|
setItem: (key: string, value: string) => { store[key] = value; },
|
||||||
|
removeItem: (key: string) => { delete store[key]; },
|
||||||
|
clear: () => { store = {}; },
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
Object.defineProperty(global, 'localStorage', {
|
||||||
|
value: localStorageMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
global.fetch = fetchMock;
|
||||||
|
|
||||||
|
describe('useSessionTimeout', () => {
|
||||||
|
const mockLogout = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
localStorage.clear();
|
||||||
|
fetchMock.mockReset();
|
||||||
|
mockLogout.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show warning when user is active', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSessionTimeout(true, mockLogout)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.isWarningOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show warning after idle period approaches timeout', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSessionTimeout(true, mockLogout)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fast-forward 12 minutes (warning threshold is 3 min before 15 min timeout)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(12 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fast-forward past the activity check interval (5 seconds)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(10 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isWarningOpen).toBe(true);
|
||||||
|
expect(result.current.countdownSeconds).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call logout when session expires', async () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSessionTimeout(true, mockLogout)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fast-forward past the 15-minute timeout
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(16 * 60 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for async logout
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(10 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLogout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extend session when extendSession is called', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce({
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
message: 'Session refreshed',
|
||||||
|
data: { expires_at: '2026-04-26T20:00:00Z' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSessionTimeout(true, mockLogout)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger warning
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(12 * 60 * 1000);
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(10 * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isWarningOpen).toBe(true);
|
||||||
|
|
||||||
|
// Extend session
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.extendSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.isWarningOpen).toBe(false);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/auth/sessions/refresh'),
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send heartbeat periodically', () => {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
code: 200,
|
||||||
|
message: 'OK',
|
||||||
|
data: { user: { id: '1', email: 'test@example.com' } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderHook(() => useSessionTimeout(true, mockLogout));
|
||||||
|
|
||||||
|
// Fast-forward past heartbeat interval (5 minutes)
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(5 * 60 * 1000 + 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/users/me'),
|
||||||
|
expect.any(Object)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout on 401 heartbeat response', async () => {
|
||||||
|
fetchMock.mockRejectedValueOnce({ code: 401, message: 'Unauthorized' });
|
||||||
|
|
||||||
|
renderHook(() => useSessionTimeout(true, mockLogout));
|
||||||
|
|
||||||
|
// Fast-forward past heartbeat interval
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(5 * 60 * 1000 + 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the async catch block
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockLogout).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user