feat: implement session management with dynamic storage and profile device tracking
This commit is contained in:
parent
ae654c41fd
commit
2a2798cd0f
@ -1,11 +1,12 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
|
||||||
import { logoutAsync } from '@/store/authSlice';
|
import { logoutAsync } from '@/store/authSlice';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { showToast } from '@/utils/toast';
|
import { showToast } from '@/utils/toast';
|
||||||
import { NotificationBell } from '@/components/shared/NotificationBell';
|
import { NotificationBell } from '@/components/shared/NotificationBell';
|
||||||
|
import { ProfileModal } from '@/components/shared/ProfileModal';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
@ -19,8 +20,16 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
|||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { user, isLoading, roles, tenantId } = useAppSelector((state) => state.auth);
|
const { user, isLoading, roles, tenantId } = useAppSelector((state) => state.auth);
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
|
||||||
|
const [isProfileModalOpen, setIsProfileModalOpen] = useState<boolean>(false);
|
||||||
|
const [profileModalTab, setProfileModalTab] = useState<'profile' | 'sessions'>('sessions');
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const openProfileModal = (tab: 'profile' | 'sessions') => {
|
||||||
|
setProfileModalTab(tab);
|
||||||
|
setIsProfileModalOpen(true);
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Get user initials for avatar
|
// Get user initials for avatar
|
||||||
const getUserInitials = (): string => {
|
const getUserInitials = (): string => {
|
||||||
if (user?.first_name && user?.last_name) {
|
if (user?.first_name && user?.last_name) {
|
||||||
@ -217,6 +226,26 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Profile & Sessions Options */}
|
||||||
|
<div className="p-1.5 border-b border-[rgba(0,0,0,0.08)] space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openProfileModal('profile')}
|
||||||
|
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm font-medium text-[#0f1724] hover:bg-gray-100 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4 text-[#6b7280]" />
|
||||||
|
<span>My Profile</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openProfileModal('sessions')}
|
||||||
|
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm font-medium text-[#0f1724] hover:bg-gray-100 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4 text-[#6b7280]" />
|
||||||
|
<span>Active Sessions</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Logout Button */}
|
{/* Logout Button */}
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<button
|
<button
|
||||||
@ -266,6 +295,26 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Profile & Sessions Options */}
|
||||||
|
<div className="p-1.5 border-b border-[rgba(0,0,0,0.08)] space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openProfileModal('profile')}
|
||||||
|
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm font-medium text-[#0f1724] hover:bg-gray-100 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4 text-[#6b7280]" />
|
||||||
|
<span>My Profile</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openProfileModal('sessions')}
|
||||||
|
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm font-medium text-[#0f1724] hover:bg-gray-100 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4 text-[#6b7280]" />
|
||||||
|
<span>Active Sessions</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Logout Button */}
|
{/* Logout Button */}
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
<button
|
<button
|
||||||
@ -282,6 +331,14 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Modal */}
|
||||||
|
<ProfileModal
|
||||||
|
isOpen={isProfileModalOpen}
|
||||||
|
onClose={() => setIsProfileModalOpen(false)}
|
||||||
|
defaultTab={profileModalTab}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
395
src/components/shared/ProfileModal.tsx
Normal file
395
src/components/shared/ProfileModal.tsx
Normal file
@ -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<SessionInfo[]>([]);
|
||||||
|
const [currentSessionId, setCurrentSessionId] = useState<string>('');
|
||||||
|
const [isLoadingSessions, setIsLoadingSessions] = useState<boolean>(false);
|
||||||
|
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null);
|
||||||
|
const [showLogoutAllConfirm, setShowLogoutAllConfirm] = useState<boolean>(false);
|
||||||
|
const [isLoggingOutAll, setIsLoggingOutAll] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setActiveTab(defaultTab);
|
||||||
|
fetchActiveSessions();
|
||||||
|
}
|
||||||
|
}, [isOpen, defaultTab]);
|
||||||
|
|
||||||
|
const getTenantDisplayName = (): string => {
|
||||||
|
return tenant?.name || 'System';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchActiveSessions = async (): Promise<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Profile Settings & Sessions"
|
||||||
|
description="Manage your account profile and active device sessions"
|
||||||
|
maxWidth="lg"
|
||||||
|
>
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-gray-200 mb-5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('profile')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 font-medium text-sm border-b-2 transition-colors -mb-px ${
|
||||||
|
activeTab === 'profile'
|
||||||
|
? 'border-[#0f1724] text-[#0f1724]'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
<span>Profile Details</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab('sessions');
|
||||||
|
fetchActiveSessions();
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2.5 font-medium text-sm border-b-2 transition-colors -mb-px ${
|
||||||
|
activeTab === 'sessions'
|
||||||
|
? 'border-[#0f1724] text-[#0f1724]'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
<span>Active Sessions</span>
|
||||||
|
{sessions.length > 0 && (
|
||||||
|
<span className="ml-1 px-2 py-0.5 text-xs font-semibold rounded-full bg-gray-100 text-gray-700">
|
||||||
|
{sessions.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile Details View */}
|
||||||
|
{activeTab === 'profile' && (
|
||||||
|
<div className="space-y-6 py-2">
|
||||||
|
<div className="flex items-center gap-4 p-4 rounded-lg bg-gray-50 border border-gray-200">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-[#0f1724] text-white flex items-center justify-center font-bold text-lg">
|
||||||
|
{user?.first_name?.[0]}
|
||||||
|
{user?.last_name?.[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-[#0f1724]">
|
||||||
|
{user?.first_name} {user?.last_name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">{user?.email}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
{roles.map((r) => (
|
||||||
|
<span
|
||||||
|
key={r}
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 border border-blue-200"
|
||||||
|
>
|
||||||
|
{r}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="p-3.5 rounded-lg border border-gray-200 bg-white">
|
||||||
|
<label className="text-xs font-medium text-gray-400 uppercase tracking-wider block mb-1">
|
||||||
|
First Name
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium text-[#0f1724]">{user?.first_name || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3.5 rounded-lg border border-gray-200 bg-white">
|
||||||
|
<label className="text-xs font-medium text-gray-400 uppercase tracking-wider block mb-1">
|
||||||
|
Last Name
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium text-[#0f1724]">{user?.last_name || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3.5 rounded-lg border border-gray-200 bg-white">
|
||||||
|
<label className="text-xs font-medium text-gray-400 uppercase tracking-wider block mb-1">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium text-[#0f1724]">{user?.email || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3.5 rounded-lg border border-gray-200 bg-white">
|
||||||
|
<label className="text-xs font-medium text-gray-400 uppercase tracking-wider block mb-1">
|
||||||
|
Tenant Context
|
||||||
|
</label>
|
||||||
|
<p className="text-sm font-medium text-[#0f1724] truncate">{getTenantDisplayName()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Sessions View */}
|
||||||
|
{activeTab === 'sessions' && (
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{/* Active Sessions Header & Action Bar */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[#0f1724]">Active Login Sessions</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
You are currently logged in on these devices. Revoke any unfamiliar session.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* <button
|
||||||
|
type="button"
|
||||||
|
onClick={fetchActiveSessions}
|
||||||
|
disabled={isLoadingSessions}
|
||||||
|
className="p-2 text-gray-600 hover:text-gray-900 bg-white border border-gray-200 rounded-md hover:bg-gray-100 transition-colors disabled:opacity-50"
|
||||||
|
title="Refresh sessions"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isLoadingSessions ? 'animate-spin' : ''}`} />
|
||||||
|
</button> */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="logout-all-devices-btn"
|
||||||
|
onClick={() => setShowLogoutAllConfirm(true)}
|
||||||
|
disabled={isLoggingOutAll}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-red-600 hover:text-white bg-red-50 hover:bg-red-600 border border-red-200 rounded-md transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
|
<span>Logout All Devices</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Logout All Dialog */}
|
||||||
|
{showLogoutAllConfirm && (
|
||||||
|
<div className="p-4 rounded-lg bg-red-50 border border-red-200 space-y-3 animate-in fade-in duration-200">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-600 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-red-900">Logout All Devices?</h4>
|
||||||
|
<p className="text-xs text-red-700 mt-0.5">
|
||||||
|
This will immediately invalidate all active login sessions across all browsers and devices. You will be redirected to the login page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowLogoutAllConfirm(false)}
|
||||||
|
disabled={isLoggingOutAll}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleLogoutAll}
|
||||||
|
disabled={isLoggingOutAll}
|
||||||
|
className="px-3 py-1.5 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoggingOutAll ? 'Logging out...' : 'Confirm Logout All Devices'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sessions List */}
|
||||||
|
{isLoadingSessions ? (
|
||||||
|
<div className="py-12 flex flex-col items-center justify-center text-gray-400 gap-2">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin text-gray-500" />
|
||||||
|
<span className="text-xs font-medium">Loading active sessions...</span>
|
||||||
|
</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-gray-500 text-sm">
|
||||||
|
No active sessions found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3 max-h-[380px] overflow-y-auto pr-1">
|
||||||
|
{sessions.map((session) => {
|
||||||
|
const { label: deviceLabel, icon: DeviceIcon } = parseDeviceType(session.user_agent);
|
||||||
|
const isCurrent = session.id === currentSessionId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={`flex items-center justify-between p-3.5 rounded-lg border transition-all ${
|
||||||
|
isCurrent
|
||||||
|
? 'bg-blue-50/50 border-blue-200'
|
||||||
|
: 'bg-white border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className={`p-2.5 rounded-lg ${isCurrent ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
<DeviceIcon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-semibold text-[#0f1724] truncate">
|
||||||
|
{deviceLabel}
|
||||||
|
</span>
|
||||||
|
{isCurrent && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold bg-blue-100 text-blue-800">
|
||||||
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
Current Device
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-500 mt-1 flex-wrap">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Globe className="w-3 h-3" />
|
||||||
|
{session.ip_address || '127.0.0.1'}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatDate(session.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{session.user_agent && (
|
||||||
|
<p className="text-[11px] text-gray-400 truncate max-w-md mt-0.5">
|
||||||
|
{session.user_agent}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCurrent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRevokeSession(session.id)}
|
||||||
|
disabled={revokingSessionId === session.id}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50 border border-red-200 rounded transition-colors disabled:opacity-50 ml-2 shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
<span>{revokingSessionId === session.id ? 'Revoking...' : 'Revoke'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -46,3 +46,5 @@ export { FormTagInput } from './FormTagInput';
|
|||||||
export { MarkdownViewer } from './MarkdownViewer';
|
export { MarkdownViewer } from './MarkdownViewer';
|
||||||
export { GradientStatCard } from './GradientStatCard';
|
export { GradientStatCard } from './GradientStatCard';
|
||||||
export { MoreFilters } from './MoreFilters';
|
export { MoreFilters } from './MoreFilters';
|
||||||
|
export { ProfileModal } from './ProfileModal';
|
||||||
|
|
||||||
|
|||||||
@ -45,6 +45,7 @@ const Login = (): ReactElement => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [generalError, setGeneralError] = useState<string>("");
|
const [generalError, setGeneralError] = useState<string>("");
|
||||||
|
const [rememberMe, setRememberMe] = useState<boolean>(false);
|
||||||
|
|
||||||
// Redirect if already authenticated
|
// Redirect if already authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -88,6 +89,7 @@ const Login = (): ReactElement => {
|
|||||||
dispatch(clearError());
|
dispatch(clearError());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
sessionStorage.setItem("remember_me", rememberMe ? "true" : "false");
|
||||||
const result = await dispatch(loginAsync(data)).unwrap();
|
const result = await dispatch(loginAsync(data)).unwrap();
|
||||||
if (result) {
|
if (result) {
|
||||||
// Check if password reset is required
|
// Check if password reset is required
|
||||||
@ -237,6 +239,8 @@ const Login = (): ReactElement => {
|
|||||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
checked={rememberMe}
|
||||||
|
onChange={(e) => setRememberMe(e.target.checked)}
|
||||||
className="rounded border-gray-300 text-[#112868] focus:ring-[#112868]/20"
|
className="rounded border-gray-300 text-[#112868] focus:ring-[#112868]/20"
|
||||||
/>
|
/>
|
||||||
Remember Me
|
Remember Me
|
||||||
|
|||||||
@ -236,6 +236,24 @@ const ElectronicSignatures = (): ReactElement => {
|
|||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
label: "Name",
|
||||||
|
render: (log) => (
|
||||||
|
<span className="text-sm font-normal text-[#475569] block max-w-xs truncate" title={log?.signer?.name}>
|
||||||
|
{log?.signer?.name}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "email",
|
||||||
|
label: "Email",
|
||||||
|
render: (log) => (
|
||||||
|
<span className="text-sm font-normal text-[#475569] block max-w-xs truncate" title={log?.signer?.email}>
|
||||||
|
{log?.signer?.email}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "meaning",
|
key: "meaning",
|
||||||
label: "Meaning",
|
label: "Meaning",
|
||||||
|
|||||||
@ -90,6 +90,7 @@ const TenantLogin = (): ReactElement => {
|
|||||||
dispatch(clearError());
|
dispatch(clearError());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
sessionStorage.setItem("remember_me", rememberMe ? "true" : "false");
|
||||||
const result = await dispatch(loginAsync(data)).unwrap();
|
const result = await dispatch(loginAsync(data)).unwrap();
|
||||||
if (result) {
|
if (result) {
|
||||||
// Check if password reset is required
|
// Check if password reset is required
|
||||||
|
|||||||
@ -33,7 +33,7 @@ export interface LoginResponse {
|
|||||||
data: {
|
data: {
|
||||||
user: User;
|
user: User;
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
// tenant: Tenant;
|
tenant?: Tenant | null;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
access_token: string;
|
access_token: string;
|
||||||
@ -114,6 +114,32 @@ export interface RefreshTokenResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionInfo {
|
||||||
|
id: string;
|
||||||
|
ip_address?: string;
|
||||||
|
user_agent?: string;
|
||||||
|
created_at?: string;
|
||||||
|
expires_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetSessionsResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
sessions: SessionInfo[];
|
||||||
|
current_session_id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevokeSessionResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogoutAllResponse {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
|
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
|
||||||
const response = await apiClient.post<LoginResponse>('/auth/login', credentials);
|
const response = await apiClient.post<LoginResponse>('/auth/login', credentials);
|
||||||
@ -124,6 +150,18 @@ export const authService = {
|
|||||||
const response = await apiClient.post<LogoutResponse>('/auth/logout', {});
|
const response = await apiClient.post<LogoutResponse>('/auth/logout', {});
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
logoutAll: async (): Promise<LogoutAllResponse> => {
|
||||||
|
const response = await apiClient.post<LogoutAllResponse>('/auth/logout-all', {});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
getSessions: async (): Promise<GetSessionsResponse> => {
|
||||||
|
const response = await apiClient.get<GetSessionsResponse>('/auth/sessions');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
revokeSession: async (sessionId: string): Promise<RevokeSessionResponse> => {
|
||||||
|
const response = await apiClient.delete<RevokeSessionResponse>(`/auth/sessions/${sessionId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
forgotPassword: async (data: ForgotPasswordRequest): Promise<ForgotPasswordResponse> => {
|
forgotPassword: async (data: ForgotPasswordRequest): Promise<ForgotPasswordResponse> => {
|
||||||
const response = await apiClient.post<ForgotPasswordResponse>('/auth/forgot-password', data);
|
const response = await apiClient.post<ForgotPasswordResponse>('/auth/forgot-password', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -137,3 +175,4 @@ export const authService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, createAsyncThunk, type PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { authService, type LoginRequest, type LoginResponse, type LoginError, type GeneralError, type Permission, type RefreshTokenResponse } from '@/services/auth-service';
|
import { authService, type LoginRequest, type LoginResponse, type LoginError, type GeneralError, type Permission, type RefreshTokenResponse, type Tenant } from '@/services/auth-service';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@ -11,7 +11,7 @@ interface User {
|
|||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
tenantId: string | null;
|
tenantId: string | null;
|
||||||
// tenant: Tenant | null;
|
tenant: Tenant | null;
|
||||||
roles: string[];
|
roles: string[];
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
@ -27,7 +27,7 @@ interface AuthState {
|
|||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
user: null,
|
user: null,
|
||||||
tenantId: null,
|
tenantId: null,
|
||||||
// tenant: null,
|
tenant: null,
|
||||||
roles: [],
|
roles: [],
|
||||||
permissions: [],
|
permissions: [],
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
@ -78,6 +78,17 @@ export const logoutAsync = createAsyncThunk<{ message?: string }, void, { reject
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Async thunk for logout all
|
||||||
|
export const logoutAllAsync = createAsyncThunk<{ message?: string }, void, { rejectValue: { message?: string } }>('auth/logoutAll', async (_, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await authService.logoutAll();
|
||||||
|
return { message: response.message };
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorData = error?.response?.data || { message: 'Logout all failed' };
|
||||||
|
return rejectWithValue({ message: errorData.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Async thunk for refresh token
|
// Async thunk for refresh token
|
||||||
export const refreshTokenAsync = createAsyncThunk<
|
export const refreshTokenAsync = createAsyncThunk<
|
||||||
RefreshTokenResponse['data'],
|
RefreshTokenResponse['data'],
|
||||||
@ -102,7 +113,7 @@ const authSlice = createSlice({
|
|||||||
logout: (state) => {
|
logout: (state) => {
|
||||||
state.user = null;
|
state.user = null;
|
||||||
state.tenantId = null;
|
state.tenantId = null;
|
||||||
// state.tenant = null;
|
state.tenant = null;
|
||||||
state.roles = [];
|
state.roles = [];
|
||||||
state.permissions = [];
|
state.permissions = [];
|
||||||
state.accessToken = null;
|
state.accessToken = null;
|
||||||
@ -138,13 +149,14 @@ const authSlice = createSlice({
|
|||||||
state.isAuthenticated = false;
|
state.isAuthenticated = false;
|
||||||
state.user = action.payload.data.user;
|
state.user = action.payload.data.user;
|
||||||
state.tenantId = action.payload.data.tenant_id;
|
state.tenantId = action.payload.data.tenant_id;
|
||||||
|
state.tenant = action.payload.data.tenant || null;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.user = action.payload.data.user;
|
state.user = action.payload.data.user;
|
||||||
state.tenantId = action.payload.data.tenant_id;
|
state.tenantId = action.payload.data.tenant_id;
|
||||||
// state.tenant = action.payload.data.tenant;
|
state.tenant = action.payload.data.tenant || null;
|
||||||
state.roles = action.payload.data.roles;
|
state.roles = action.payload.data.roles;
|
||||||
state.permissions = action.payload.data.permissions || [];
|
state.permissions = action.payload.data.permissions || [];
|
||||||
state.accessToken = action.payload.data.access_token;
|
state.accessToken = action.payload.data.access_token;
|
||||||
@ -206,6 +218,37 @@ const authSlice = createSlice({
|
|||||||
state.isLoading = false;
|
state.isLoading = false;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
})
|
})
|
||||||
|
.addCase(logoutAllAsync.pending, (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
})
|
||||||
|
.addCase(logoutAllAsync.fulfilled, (state) => {
|
||||||
|
state.user = null;
|
||||||
|
state.tenantId = null;
|
||||||
|
state.roles = [];
|
||||||
|
state.permissions = [];
|
||||||
|
state.accessToken = null;
|
||||||
|
state.refreshToken = null;
|
||||||
|
state.tokenType = null;
|
||||||
|
state.expiresIn = null;
|
||||||
|
state.expiresAt = null;
|
||||||
|
state.isAuthenticated = false;
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(logoutAllAsync.rejected, (state) => {
|
||||||
|
state.user = null;
|
||||||
|
state.tenantId = null;
|
||||||
|
state.roles = [];
|
||||||
|
state.permissions = [];
|
||||||
|
state.accessToken = null;
|
||||||
|
state.refreshToken = null;
|
||||||
|
state.tokenType = null;
|
||||||
|
state.expiresIn = null;
|
||||||
|
state.expiresAt = null;
|
||||||
|
state.isAuthenticated = false;
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
.addCase(refreshTokenAsync.fulfilled, (state, action: PayloadAction<RefreshTokenResponse['data']>) => {
|
.addCase(refreshTokenAsync.fulfilled, (state, action: PayloadAction<RefreshTokenResponse['data']>) => {
|
||||||
state.accessToken = action.payload.access_token;
|
state.accessToken = action.payload.access_token;
|
||||||
state.refreshToken = action.payload.refresh_token;
|
state.refreshToken = action.payload.refresh_token;
|
||||||
|
|||||||
@ -1,14 +1,53 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import { persistStore, persistReducer } from 'redux-persist';
|
import { persistStore, persistReducer, type WebStorage } from 'redux-persist';
|
||||||
import storage from 'redux-persist/lib/storage';
|
|
||||||
import authReducer from './authSlice';
|
import authReducer from './authSlice';
|
||||||
import themeReducer from './themeSlice';
|
import themeReducer from './themeSlice';
|
||||||
import notificationReducer from './notificationSlice';
|
import notificationReducer from './notificationSlice';
|
||||||
|
|
||||||
|
// Dynamic storage engine supporting Remember Me (localStorage vs sessionStorage)
|
||||||
|
const dynamicStorage: WebStorage = {
|
||||||
|
getItem: (key: string) => {
|
||||||
|
try {
|
||||||
|
const sessionData = sessionStorage.getItem(key);
|
||||||
|
if (sessionData !== null) {
|
||||||
|
return Promise.resolve(sessionData);
|
||||||
|
}
|
||||||
|
return Promise.resolve(localStorage.getItem(key));
|
||||||
|
} catch {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
const isRememberMe = sessionStorage.getItem('remember_me') === 'true';
|
||||||
|
if (isRememberMe) {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
} else {
|
||||||
|
sessionStorage.setItem(key, value);
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to write auth persistence:', e);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
sessionStorage.removeItem('remember_me');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to remove auth persistence:', e);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Persist config for auth slice only
|
// Persist config for auth slice only
|
||||||
const authPersistConfig = {
|
const authPersistConfig = {
|
||||||
key: 'auth',
|
key: 'auth',
|
||||||
storage,
|
storage: dynamicStorage,
|
||||||
whitelist: ['user', 'tenantId', 'tenant', 'roles', 'permissions', 'accessToken', 'refreshToken', 'tokenType', 'expiresIn', 'expiresAt', 'isAuthenticated'],
|
whitelist: ['user', 'tenantId', 'tenant', 'roles', 'permissions', 'accessToken', 'refreshToken', 'tokenType', 'expiresIn', 'expiresAt', 'isAuthenticated'],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user