From 2a2798cd0f02b3a3e27e7f4b5c37171b930a4ab7 Mon Sep 17 00:00:00 2001 From: SibarchanNayak Date: Mon, 20 Jul 2026 18:01:52 +0530 Subject: [PATCH] feat: implement session management with dynamic storage and profile device tracking --- src/components/layout/Header.tsx | 59 +++- src/components/shared/ProfileModal.tsx | 395 ++++++++++++++++++++++ src/components/shared/index.ts | 2 + src/pages/Login.tsx | 4 + src/pages/tenant/ElectronicSignatures.tsx | 18 + src/pages/tenant/TenantLogin.tsx | 1 + src/services/auth-service.ts | 41 ++- src/store/authSlice.ts | 53 ++- src/store/store.ts | 45 ++- 9 files changed, 608 insertions(+), 10 deletions(-) create mode 100644 src/components/shared/ProfileModal.tsx diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 580e6c3..34701f3 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,11 +1,12 @@ import { useState, useRef, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { ChevronDown, Menu, LogOut, User, ChevronRight } from 'lucide-react'; +import { ChevronDown, Menu, LogOut, User, ChevronRight, Shield } from 'lucide-react'; import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks'; import { logoutAsync } from '@/store/authSlice'; import { cn } from '@/lib/utils'; import { showToast } from '@/utils/toast'; import { NotificationBell } from '@/components/shared/NotificationBell'; +import { ProfileModal } from '@/components/shared/ProfileModal'; import type { ReactElement } from 'react'; interface HeaderProps { @@ -19,8 +20,16 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps): const dispatch = useAppDispatch(); const { user, isLoading, roles, tenantId } = useAppSelector((state) => state.auth); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); + const [profileModalTab, setProfileModalTab] = useState<'profile' | 'sessions'>('sessions'); const dropdownRef = useRef(null); + const openProfileModal = (tab: 'profile' | 'sessions') => { + setProfileModalTab(tab); + setIsProfileModalOpen(true); + setIsDropdownOpen(false); + }; + // Get user initials for avatar const getUserInitials = (): string => { if (user?.first_name && user?.last_name) { @@ -217,6 +226,26 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps): + {/* Profile & Sessions Options */} +
+ + +
+ {/* Logout Button */}
+ {/* Profile & Sessions Options */} +
+ + +
+ {/* Logout Button */}
+ + {/* Profile Modal */} + setIsProfileModalOpen(false)} + defaultTab={profileModalTab} + /> ); }; + diff --git a/src/components/shared/ProfileModal.tsx b/src/components/shared/ProfileModal.tsx new file mode 100644 index 0000000..8848e3d --- /dev/null +++ b/src/components/shared/ProfileModal.tsx @@ -0,0 +1,395 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + User, + Shield, + Laptop, + Smartphone, + Globe, + Clock, + LogOut, + Trash2, + RefreshCw, + AlertTriangle, + CheckCircle2, +} from 'lucide-react'; +import { Modal } from '@/components/shared/Modal'; +import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks'; +import { logoutAllAsync } from '@/store/authSlice'; +import { authService, type SessionInfo } from '@/services/auth-service'; +import { showToast } from '@/utils/toast'; +import type { ReactElement } from 'react'; + +interface ProfileModalProps { + isOpen: boolean; + onClose: () => void; + defaultTab?: 'profile' | 'sessions'; +} + +export const ProfileModal = ({ + isOpen, + onClose, + defaultTab = 'sessions', +}: ProfileModalProps): ReactElement | null => { + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const { user, roles, tenantId, tenant } = useAppSelector((state) => state.auth); + + const [activeTab, setActiveTab] = useState<'profile' | 'sessions'>(defaultTab); + const [sessions, setSessions] = useState([]); + const [currentSessionId, setCurrentSessionId] = useState(''); + const [isLoadingSessions, setIsLoadingSessions] = useState(false); + const [revokingSessionId, setRevokingSessionId] = useState(null); + const [showLogoutAllConfirm, setShowLogoutAllConfirm] = useState(false); + const [isLoggingOutAll, setIsLoggingOutAll] = useState(false); + + useEffect(() => { + if (isOpen) { + setActiveTab(defaultTab); + fetchActiveSessions(); + } + }, [isOpen, defaultTab]); + + const getTenantDisplayName = (): string => { + return tenant?.name || 'System'; + }; + + const fetchActiveSessions = async (): Promise => { + setIsLoadingSessions(true); + try { + const response = await authService.getSessions(); + if (response.success && response.data) { + setSessions(response.data.sessions || []); + setCurrentSessionId(response.data.current_session_id || ''); + } + } catch (error: any) { + console.error('Failed to fetch active sessions:', error); + showToast.error('Failed to load active sessions'); + } finally { + setIsLoadingSessions(false); + } + }; + + const handleRevokeSession = async (sessionId: string): Promise => { + setRevokingSessionId(sessionId); + try { + const response = await authService.revokeSession(sessionId); + if (response.success !== false) { + showToast.success('Session revoked.'); + setSessions((prev) => prev.filter((s) => s.id !== sessionId)); + } else { + showToast.error(response.message || 'Failed to revoke session'); + } + } catch (error: any) { + console.error('Revoke session error:', error); + showToast.error(error?.response?.data?.message || 'Failed to revoke session'); + } finally { + setRevokingSessionId(null); + } + }; + + const handleLogoutAll = async (): Promise => { + setIsLoggingOutAll(true); + const isPlatformUser = + roles.includes('super_admin') || tenantId === '00000000-0000-0000-0000-000000000001'; + const isTenantRoute = + window.location.pathname.startsWith('/tenant/') || window.location.pathname === '/tenant'; + const redirectPath = isPlatformUser ? '/' : isTenantRoute ? '/tenant/login' : '/'; + + try { + await dispatch(logoutAllAsync()).unwrap(); + showToast.success('Logged out from all sessions'); + onClose(); + navigate(redirectPath, { replace: true }); + } catch (error: any) { + console.error('Logout all error:', error); + dispatch({ type: 'auth/logout' }); + showToast.success('Logged out from all sessions'); + onClose(); + navigate(redirectPath, { replace: true }); + } finally { + setIsLoggingOutAll(false); + setShowLogoutAllConfirm(false); + } + }; + + const parseDeviceType = (userAgent?: string) => { + if (!userAgent) return { label: 'Web Browser', icon: Laptop }; + const ua = userAgent.toLowerCase(); + if (ua.includes('mobi') || ua.includes('android') || ua.includes('iphone')) { + return { label: 'Mobile Device', icon: Smartphone }; + } + if (ua.includes('windows')) { + return { label: 'Windows PC', icon: Laptop }; + } + if (ua.includes('macintosh') || ua.includes('mac os')) { + return { label: 'Mac / macOS', icon: Laptop }; + } + if (ua.includes('linux')) { + return { label: 'Linux Workstation', icon: Laptop }; + } + return { label: 'Web Browser', icon: Globe }; + }; + + const formatDate = (dateStr?: string): string => { + if (!dateStr) return 'N/A'; + try { + const date = new Date(dateStr); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return dateStr; + } + }; + + return ( + + {/* Tabs */} +
+ + +
+ + {/* Profile Details View */} + {activeTab === 'profile' && ( +
+
+
+ {user?.first_name?.[0]} + {user?.last_name?.[0]} +
+
+

+ {user?.first_name} {user?.last_name} +

+

{user?.email}

+
+ {roles.map((r) => ( + + {r} + + ))} +
+
+
+ +
+
+ +

{user?.first_name || 'N/A'}

+
+
+ +

{user?.last_name || 'N/A'}

+
+
+ +

{user?.email || 'N/A'}

+
+
+ +

{getTenantDisplayName()}

+
+
+
+ )} + + {/* Active Sessions View */} + {activeTab === 'sessions' && ( +
+ {/* Active Sessions Header & Action Bar */} +
+
+

Active Login Sessions

+

+ You are currently logged in on these devices. Revoke any unfamiliar session. +

+
+
+ {/* */} + +
+
+ + {/* Confirm Logout All Dialog */} + {showLogoutAllConfirm && ( +
+
+ +
+

Logout All Devices?

+

+ This will immediately invalidate all active login sessions across all browsers and devices. You will be redirected to the login page. +

+
+
+
+ + +
+
+ )} + + {/* Sessions List */} + {isLoadingSessions ? ( +
+ + Loading active sessions... +
+ ) : sessions.length === 0 ? ( +
+ No active sessions found. +
+ ) : ( +
+ {sessions.map((session) => { + const { label: deviceLabel, icon: DeviceIcon } = parseDeviceType(session.user_agent); + const isCurrent = session.id === currentSessionId; + + return ( +
+
+
+ +
+
+
+ + {deviceLabel} + + {isCurrent && ( + + + Current Device + + )} +
+
+ + + {session.ip_address || '127.0.0.1'} + + + + {formatDate(session.created_at)} + +
+ {session.user_agent && ( +

+ {session.user_agent} +

+ )} +
+
+ + {!isCurrent && ( + + )} +
+ ); + })} +
+ )} +
+ )} +
+ ); +}; diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index b6029e6..e6ce646 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -46,3 +46,5 @@ export { FormTagInput } from './FormTagInput'; export { MarkdownViewer } from './MarkdownViewer'; export { GradientStatCard } from './GradientStatCard'; export { MoreFilters } from './MoreFilters'; +export { ProfileModal } from './ProfileModal'; + diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 9c6c7e5..856f111 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -45,6 +45,7 @@ const Login = (): ReactElement => { }); const [generalError, setGeneralError] = useState(""); + const [rememberMe, setRememberMe] = useState(false); // Redirect if already authenticated useEffect(() => { @@ -88,6 +89,7 @@ const Login = (): ReactElement => { dispatch(clearError()); try { + sessionStorage.setItem("remember_me", rememberMe ? "true" : "false"); const result = await dispatch(loginAsync(data)).unwrap(); if (result) { // Check if password reset is required @@ -237,6 +239,8 @@ const Login = (): ReactElement => {