feat(auth): Implemented Session Persistence
This commit is contained in:
53
apps/web/src/common/components/PageHeader.tsx
Normal file
53
apps/web/src/common/components/PageHeader.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
backTo?: string | null;
|
||||||
|
backButtonLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
actions = null,
|
||||||
|
backTo = null,
|
||||||
|
backButtonLabel = "Back"
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
{/* Back Button */}
|
||||||
|
{backTo && (
|
||||||
|
<Link to={backTo} className="inline-block mb-4">
|
||||||
|
<Button variant="ghost" className="hover:bg-muted">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
{backButtonLabel}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl md:text-4xl font-bold text-foreground mb-2">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-lg text-muted-foreground">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Actions (if provided) */}
|
||||||
|
{actions && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ type LocationState = {
|
|||||||
from?: {
|
from?: {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
};
|
};
|
||||||
|
message?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
const Login: React.FC = () => {
|
||||||
@@ -29,6 +30,7 @@ const Login: React.FC = () => {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [emailError, setEmailError] = useState("");
|
const [emailError, setEmailError] = useState("");
|
||||||
const [passwordError, setPasswordError] = useState("");
|
const [passwordError, setPasswordError] = useState("");
|
||||||
|
const [sessionMessage, setSessionMessage] = useState<string | null>(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
@@ -87,6 +89,18 @@ const Login: React.FC = () => {
|
|||||||
navigate(from ?? dashboardPath, { replace: true });
|
navigate(from ?? dashboardPath, { replace: true });
|
||||||
}, [isAuthenticated, user?.userRole, navigate, location.state]);
|
}, [isAuthenticated, user?.userRole, navigate, location.state]);
|
||||||
|
|
||||||
|
// Check for session expiration message from redirect
|
||||||
|
useEffect(() => {
|
||||||
|
const state = location.state as LocationState | null;
|
||||||
|
if (state?.message) {
|
||||||
|
// Auto-clear session message after 5 seconds
|
||||||
|
const timer = setTimeout(() => setSessionMessage(null), 5000);
|
||||||
|
// Use a microtask to avoid cascading renders
|
||||||
|
Promise.resolve().then(() => setSessionMessage(state.message || null));
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [location.state]);
|
||||||
|
|
||||||
// Clear error message when component unmounts
|
// Clear error message when component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -169,6 +183,13 @@ const Login: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{sessionMessage && (
|
||||||
|
<div className="flex items-center p-4 text-sm text-blue-700 bg-blue-50 border border-blue-200 rounded-xl transition-all animate-in fade-in slide-in-from-top-2">
|
||||||
|
<AlertCircle className="w-4 h-4 mr-2 shrink-0" />
|
||||||
|
<span>{sessionMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{reduxError && (
|
{reduxError && (
|
||||||
<div className="flex items-center p-4 text-sm text-destructive-foreground bg-destructive/70 border border-destructive/20 rounded-xl transition-all animate-in fade-in slide-in-from-top-2">
|
<div className="flex items-center p-4 text-sm text-destructive-foreground bg-destructive/70 border border-destructive/20 rounded-xl transition-all animate-in fade-in slide-in-from-top-2">
|
||||||
<AlertCircle className="w-4 h-4 mr-2 shrink-0" />
|
<AlertCircle className="w-4 h-4 mr-2 shrink-0" />
|
||||||
|
|||||||
27
apps/web/src/features/dashboard/RoleDashboardRedirect.tsx
Normal file
27
apps/web/src/features/dashboard/RoleDashboardRedirect.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import React, {useEffect} from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { getDashboardPath } from '../../services/firestoreService'
|
||||||
|
import type { RootState } from '../../store/store';
|
||||||
|
/**
|
||||||
|
* RoleDashboardRedirect Component
|
||||||
|
* Dynamically redirects users to their appropriate dashboard based on their role
|
||||||
|
*/
|
||||||
|
export const RoleDashboardRedirect: React.FC = () => {
|
||||||
|
const { isAuthenticated, user } = useSelector((state: RootState) => state.auth);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user?.userRole) {
|
||||||
|
const dashboardPath = getDashboardPath(user.userRole);
|
||||||
|
navigate(dashboardPath, { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, user?.userRole, navigate]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar from './Sidebar';
|
||||||
import { getDashboardPath } from '../../services/firestoreService';
|
import { getDashboardPath } from '../../services/firestoreService';
|
||||||
|
import { useSessionPersistence } from '../../hooks/useSessionPersistence';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main Application Layout for Authenticated Users.
|
* Main Application Layout for Authenticated Users.
|
||||||
@@ -17,6 +18,9 @@ import { getDashboardPath } from '../../services/firestoreService';
|
|||||||
* Handles role-based navigation rendering and validates user access to dashboard routes.
|
* Handles role-based navigation rendering and validates user access to dashboard routes.
|
||||||
*/
|
*/
|
||||||
const AppLayout: React.FC = () => {
|
const AppLayout: React.FC = () => {
|
||||||
|
// Initialize session persistence and token refresh
|
||||||
|
useSessionPersistence();
|
||||||
|
|
||||||
// Typed selectors
|
// Typed selectors
|
||||||
const { isAuthenticated, user } = useSelector((state: RootState) => state.auth);
|
const { isAuthenticated, user } = useSelector((state: RootState) => state.auth);
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
@@ -47,7 +51,10 @@ const AppLayout: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
dispatch(logoutUser());
|
dispatch(logoutUser()).then(() => {
|
||||||
|
// Navigate to login page after logout is complete
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
48
apps/web/src/features/layouts/DashboardLayout.tsx
Normal file
48
apps/web/src/features/layouts/DashboardLayout.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PageHeader from '../../common/components/PageHeader';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface DashboardLayoutProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
maxWidth?: string;
|
||||||
|
backAction?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardLayout: React.FC<DashboardLayoutProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
maxWidth = 'max-w-7xl',
|
||||||
|
backAction
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8">
|
||||||
|
<div className={`${maxWidth} mx-auto`}>
|
||||||
|
{backAction && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
{backAction}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PageHeader
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardLayout;
|
||||||
23
apps/web/src/features/layouts/PublicLayout.tsx
Normal file
23
apps/web/src/features/layouts/PublicLayout.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface PublicLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout for public, unauthenticated pages (e.g. Login, Forgot Password).
|
||||||
|
* Provides a centered container with a gradient background.
|
||||||
|
*/
|
||||||
|
const PublicLayout: React.FC<PublicLayoutProps> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen w-full bg-slate-50 flex items-center justify-center">
|
||||||
|
{/* Background decoration */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-indigo-50 -z-10" />
|
||||||
|
<div className="w-full">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PublicLayout;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { LogOut, Menu, X } from 'lucide-react';
|
import { LogOut, Menu, X } from 'lucide-react';
|
||||||
import { Button } from '../../common/components/ui/button';
|
import { Button } from '../../common/components/ui/button';
|
||||||
import { NAV_CONFIG } from "../../common/config/navigation";
|
import { NAV_CONFIG } from "../../common/config/navigation";
|
||||||
@@ -22,6 +22,19 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
onLogout
|
onLogout
|
||||||
}) => {
|
}) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle logout with navigation
|
||||||
|
* Ensures user is redirected to login page after logout
|
||||||
|
*/
|
||||||
|
const handleLogoutClick = async () => {
|
||||||
|
onLogout();
|
||||||
|
// Small delay to allow logout to complete before navigation
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
// Filter navigation based on user role
|
// Filter navigation based on user role
|
||||||
const filteredNav = useMemo(() => {
|
const filteredNav = useMemo(() => {
|
||||||
@@ -107,7 +120,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={onLogout}
|
onClick={handleLogoutClick}
|
||||||
title="Logout"
|
title="Logout"
|
||||||
className="text-secondary-text hover:text-destructive hover:bg-destructive/10"
|
className="text-secondary-text hover:text-destructive hover:bg-destructive/10"
|
||||||
>
|
>
|
||||||
|
|||||||
127
apps/web/src/hooks/useSessionPersistence.ts
Normal file
127
apps/web/src/hooks/useSessionPersistence.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
subscribeToAuthState,
|
||||||
|
refreshUserToken,
|
||||||
|
stopTokenRefreshTimer
|
||||||
|
} from '../services/authService';
|
||||||
|
import { checkAuthStatus, logoutUser } from '../features/auth/authSlice';
|
||||||
|
import type { RootState, AppDispatch } from '../store/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing session persistence and token refresh
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* 1. Initialize auth state on app load and restore persisted sessions
|
||||||
|
* 2. Manage automatic token refresh to prevent session expiry
|
||||||
|
* 3. Detect and handle token expiration
|
||||||
|
* 4. Set up activity monitoring (optional, can extend for activity-based timeouts)
|
||||||
|
*
|
||||||
|
* Usage: Call this hook in AppLayout or a root component that wraps authenticated routes
|
||||||
|
*/
|
||||||
|
export const useSessionPersistence = () => {
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isAuthenticated, user } = useSelector((state: RootState) => state.auth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle token expiration by logging out user and redirecting to login
|
||||||
|
*/
|
||||||
|
const handleTokenExpiration = useCallback(async () => {
|
||||||
|
console.warn('Token expired, logging out user');
|
||||||
|
await dispatch(logoutUser());
|
||||||
|
navigate('/login', { replace: true, state: { message: 'Your session has expired. Please log in again.' } });
|
||||||
|
}, [dispatch, navigate]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize session on component mount
|
||||||
|
* Restores persisted session from Firebase and sets up auth listeners
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
const initializeSession = async () => {
|
||||||
|
try {
|
||||||
|
// Check if user is already logged in (from Firebase persistence)
|
||||||
|
await dispatch(checkAuthStatus());
|
||||||
|
|
||||||
|
// Set up real-time auth state listener
|
||||||
|
unsubscribe = subscribeToAuthState(async (firebaseUser) => {
|
||||||
|
if (firebaseUser) {
|
||||||
|
// User is authenticated - token refresh is started by subscribeToAuthState
|
||||||
|
console.log('User session restored:', firebaseUser.email);
|
||||||
|
} else {
|
||||||
|
// User is not authenticated
|
||||||
|
stopTokenRefreshTimer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing session:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeSession();
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
if (unsubscribe) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
stopTokenRefreshTimer();
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitor token validity and handle expiration
|
||||||
|
* Periodically checks if token is still valid
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated || !user) return;
|
||||||
|
|
||||||
|
// Set up interval to check token validity every 5 minutes
|
||||||
|
const tokenCheckInterval = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
// Attempt to get fresh token - this will throw if token is invalid/expired
|
||||||
|
const success = await refreshUserToken();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
// Token refresh failed
|
||||||
|
handleTokenExpiration();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token validation failed:', error);
|
||||||
|
handleTokenExpiration();
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000); // Check every 5 minutes
|
||||||
|
|
||||||
|
// Cleanup interval
|
||||||
|
return () => clearInterval(tokenCheckInterval);
|
||||||
|
}, [isAuthenticated, user, handleTokenExpiration]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last activity timestamp on user interaction
|
||||||
|
* This can be used to implement idle timeout if needed in the future
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
|
const updateActivity = () => {
|
||||||
|
localStorage.setItem('lastActivityTime', Date.now().toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track user activity
|
||||||
|
const events = ['mousedown', 'keydown', 'scroll', 'touchstart', 'click'];
|
||||||
|
|
||||||
|
events.forEach(event => {
|
||||||
|
window.addEventListener(event, updateActivity);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup event listeners
|
||||||
|
return () => {
|
||||||
|
events.forEach(event => {
|
||||||
|
window.removeEventListener(event, updateActivity);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
};
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate, useNavigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import Login from './features/auth/Login';
|
import Login from './features/auth/Login';
|
||||||
import ForgotPassword from './features/auth/ForgotPassword';
|
import ForgotPassword from './features/auth/ForgotPassword';
|
||||||
import AppLayout from './features/layouts/AppLayout';
|
import AppLayout from './features/layouts/AppLayout';
|
||||||
@@ -8,8 +7,8 @@ import AdminDashboard from './features/dashboard/AdminDashboard';
|
|||||||
import ClientDashboard from './features/dashboard/ClientDashboard';
|
import ClientDashboard from './features/dashboard/ClientDashboard';
|
||||||
import VendorDashboard from './features/dashboard/VendorDashboard';
|
import VendorDashboard from './features/dashboard/VendorDashboard';
|
||||||
import ProtectedRoute from './features/layouts/ProtectedRoute';
|
import ProtectedRoute from './features/layouts/ProtectedRoute';
|
||||||
import { getDashboardPath } from './services/firestoreService';
|
import { RoleDashboardRedirect } from './features/dashboard/RoleDashboardRedirect';
|
||||||
import type { RootState } from './store/store';
|
import PublicLayout from './features/layouts/PublicLayout';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppRoutes Component
|
* AppRoutes Component
|
||||||
@@ -25,7 +24,9 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={
|
element={
|
||||||
<Login />
|
<PublicLayout>
|
||||||
|
<Login />
|
||||||
|
</PublicLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
@@ -80,27 +81,5 @@ const AppRoutes: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* RoleDashboardRedirect Component
|
|
||||||
* Dynamically redirects users to their appropriate dashboard based on their role
|
|
||||||
*/
|
|
||||||
const RoleDashboardRedirect: React.FC = () => {
|
|
||||||
const { isAuthenticated, user } = useSelector((state: RootState) => state.auth);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
navigate('/login', { replace: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user?.userRole) {
|
|
||||||
const dashboardPath = getDashboardPath(user.userRole);
|
|
||||||
navigate(dashboardPath, { replace: true });
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, user?.userRole, navigate]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AppRoutes;
|
export default AppRoutes;
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import { getAuth } from "firebase/auth";
|
|||||||
|
|
||||||
const auth = getAuth(app);
|
const auth = getAuth(app);
|
||||||
|
|
||||||
|
// Token refresh interval tracking
|
||||||
|
let tokenRefreshInterval: number | null = null;
|
||||||
|
|
||||||
|
// Constants for session management
|
||||||
|
const TOKEN_REFRESH_INTERVAL = 40 * 60 * 1000; // Refresh token every 40 minutes (Firebase ID tokens expire in 1 hour)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize Firebase Auth persistence
|
* Initialize Firebase Auth persistence
|
||||||
* Ensures user session persists across page refreshes
|
* Ensures user session persists across page refreshes
|
||||||
@@ -20,11 +26,64 @@ const auth = getAuth(app);
|
|||||||
export const initializeAuthPersistence = async () => {
|
export const initializeAuthPersistence = async () => {
|
||||||
try {
|
try {
|
||||||
await setPersistence(auth, browserLocalPersistence);
|
await setPersistence(auth, browserLocalPersistence);
|
||||||
|
console.log("Auth persistence initialized with localStorage");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error setting auth persistence:", error);
|
console.error("Error setting auth persistence:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the current user's ID token to maintain session validity
|
||||||
|
* Firebase automatically refreshes tokens, but we can force a refresh to ensure validity
|
||||||
|
* @returns Promise<boolean> - true if refresh successful, false otherwise
|
||||||
|
*/
|
||||||
|
export const refreshUserToken = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const currentUser = auth.currentUser;
|
||||||
|
if (currentUser) {
|
||||||
|
await currentUser.getIdTokenResult(true); // Force refresh
|
||||||
|
console.log("Token refreshed successfully");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error refreshing token:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start automatic token refresh mechanism
|
||||||
|
* Refreshes token periodically to prevent unexpected logouts
|
||||||
|
*/
|
||||||
|
export const startTokenRefreshTimer = () => {
|
||||||
|
// Clear any existing interval
|
||||||
|
if (tokenRefreshInterval) {
|
||||||
|
clearInterval(tokenRefreshInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up auto-refresh interval
|
||||||
|
tokenRefreshInterval = window.setInterval(async () => {
|
||||||
|
const currentUser = auth.currentUser;
|
||||||
|
if (currentUser) {
|
||||||
|
await refreshUserToken();
|
||||||
|
} else {
|
||||||
|
// If no user, stop the refresh timer
|
||||||
|
stopTokenRefreshTimer();
|
||||||
|
}
|
||||||
|
}, TOKEN_REFRESH_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the automatic token refresh timer
|
||||||
|
*/
|
||||||
|
export const stopTokenRefreshTimer = () => {
|
||||||
|
if (tokenRefreshInterval) {
|
||||||
|
clearInterval(tokenRefreshInterval);
|
||||||
|
tokenRefreshInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login user with email and password
|
* Login user with email and password
|
||||||
*/
|
*/
|
||||||
@@ -48,12 +107,27 @@ export const loginWithEmail = async (email: string, password: string) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign out the current user
|
* Sign out the current user
|
||||||
|
* Clears session data, local storage, and stops token refresh
|
||||||
*/
|
*/
|
||||||
export const logout = async () => {
|
export const logout = async () => {
|
||||||
try {
|
try {
|
||||||
|
// Stop token refresh interval
|
||||||
|
stopTokenRefreshTimer();
|
||||||
|
|
||||||
|
// Clear any session-related data from localStorage
|
||||||
|
localStorage.removeItem('lastActivityTime');
|
||||||
|
localStorage.removeItem('sessionStartTime');
|
||||||
|
|
||||||
|
// Sign out from Firebase
|
||||||
await signOut(auth);
|
await signOut(auth);
|
||||||
|
|
||||||
|
// Clear any other app-specific session data if needed
|
||||||
|
sessionStorage.clear();
|
||||||
|
|
||||||
|
console.log("User logged out successfully");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Error during logout:", error);
|
||||||
return { success: false, error: (error as Error).message };
|
return { success: false, error: (error as Error).message };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -87,10 +161,23 @@ export const resetPassword = async (code: string, newPassword: string) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to auth state changes
|
* Subscribe to auth state changes
|
||||||
|
* Sets up token refresh timer when user logs in, stops it when logs out
|
||||||
* Returns unsubscribe function
|
* Returns unsubscribe function
|
||||||
*/
|
*/
|
||||||
export const subscribeToAuthState = (callback: (user: User | null) => void) => {
|
export const subscribeToAuthState = (callback: (user: User | null) => void) => {
|
||||||
return onAuthStateChanged(auth, callback);
|
return onAuthStateChanged(auth, (user) => {
|
||||||
|
if (user) {
|
||||||
|
// User logged in - start token refresh
|
||||||
|
startTokenRefreshTimer();
|
||||||
|
// Update last activity time
|
||||||
|
localStorage.setItem('lastActivityTime', Date.now().toString());
|
||||||
|
localStorage.setItem('sessionStartTime', Date.now().toString());
|
||||||
|
} else {
|
||||||
|
// User logged out - stop token refresh
|
||||||
|
stopTokenRefreshTimer();
|
||||||
|
}
|
||||||
|
callback(user);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user