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 { 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<boolean>(false);
|
||||
const [isProfileModalOpen, setIsProfileModalOpen] = useState<boolean>(false);
|
||||
const [profileModalTab, setProfileModalTab] = useState<'profile' | 'sessions'>('sessions');
|
||||
const dropdownRef = useRef<HTMLDivElement>(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):
|
||||
</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 */}
|
||||
<div className="p-2">
|
||||
<button
|
||||
@ -266,6 +295,26 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
||||
</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 */}
|
||||
<div className="p-2">
|
||||
<button
|
||||
@ -282,6 +331,14 @@ export const Header = ({ breadcrumbs, currentPage, onMenuClick }: HeaderProps):
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Modal */}
|
||||
<ProfileModal
|
||||
isOpen={isProfileModalOpen}
|
||||
onClose={() => setIsProfileModalOpen(false)}
|
||||
defaultTab={profileModalTab}
|
||||
/>
|
||||
</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 { GradientStatCard } from './GradientStatCard';
|
||||
export { MoreFilters } from './MoreFilters';
|
||||
export { ProfileModal } from './ProfileModal';
|
||||
|
||||
|
||||
@ -45,6 +45,7 @@ const Login = (): ReactElement => {
|
||||
});
|
||||
|
||||
const [generalError, setGeneralError] = useState<string>("");
|
||||
const [rememberMe, setRememberMe] = useState<boolean>(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 => {
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className="rounded border-gray-300 text-[#112868] focus:ring-[#112868]/20"
|
||||
/>
|
||||
Remember Me
|
||||
|
||||
@ -236,6 +236,24 @@ const ElectronicSignatures = (): ReactElement => {
|
||||
</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",
|
||||
label: "Meaning",
|
||||
|
||||
@ -90,6 +90,7 @@ const TenantLogin = (): 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
|
||||
|
||||
@ -33,7 +33,7 @@ export interface LoginResponse {
|
||||
data: {
|
||||
user: User;
|
||||
tenant_id: string;
|
||||
// tenant: Tenant;
|
||||
tenant?: Tenant | null;
|
||||
roles: string[];
|
||||
permissions: Permission[];
|
||||
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 = {
|
||||
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>('/auth/login', credentials);
|
||||
@ -124,6 +150,18 @@ export const authService = {
|
||||
const response = await apiClient.post<LogoutResponse>('/auth/logout', {});
|
||||
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> => {
|
||||
const response = await apiClient.post<ForgotPasswordResponse>('/auth/forgot-password', data);
|
||||
return response.data;
|
||||
@ -137,3 +175,4 @@ export const authService = {
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 {
|
||||
id: string;
|
||||
@ -11,7 +11,7 @@ interface User {
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
tenantId: string | null;
|
||||
// tenant: Tenant | null;
|
||||
tenant: Tenant | null;
|
||||
roles: string[];
|
||||
permissions: Permission[];
|
||||
accessToken: string | null;
|
||||
@ -27,7 +27,7 @@ interface AuthState {
|
||||
const initialState: AuthState = {
|
||||
user: null,
|
||||
tenantId: null,
|
||||
// tenant: null,
|
||||
tenant: null,
|
||||
roles: [],
|
||||
permissions: [],
|
||||
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
|
||||
export const refreshTokenAsync = createAsyncThunk<
|
||||
RefreshTokenResponse['data'],
|
||||
@ -102,7 +113,7 @@ const authSlice = createSlice({
|
||||
logout: (state) => {
|
||||
state.user = null;
|
||||
state.tenantId = null;
|
||||
// state.tenant = null;
|
||||
state.tenant = null;
|
||||
state.roles = [];
|
||||
state.permissions = [];
|
||||
state.accessToken = null;
|
||||
@ -138,13 +149,14 @@ const authSlice = createSlice({
|
||||
state.isAuthenticated = false;
|
||||
state.user = action.payload.data.user;
|
||||
state.tenantId = action.payload.data.tenant_id;
|
||||
state.tenant = action.payload.data.tenant || null;
|
||||
state.error = null;
|
||||
return;
|
||||
}
|
||||
|
||||
state.user = action.payload.data.user;
|
||||
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.permissions = action.payload.data.permissions || [];
|
||||
state.accessToken = action.payload.data.access_token;
|
||||
@ -206,6 +218,37 @@ const authSlice = createSlice({
|
||||
state.isLoading = false;
|
||||
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']>) => {
|
||||
state.accessToken = action.payload.access_token;
|
||||
state.refreshToken = action.payload.refresh_token;
|
||||
|
||||
@ -1,14 +1,53 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { persistStore, persistReducer } from 'redux-persist';
|
||||
import storage from 'redux-persist/lib/storage';
|
||||
import { persistStore, persistReducer, type WebStorage } from 'redux-persist';
|
||||
import authReducer from './authSlice';
|
||||
import themeReducer from './themeSlice';
|
||||
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
|
||||
const authPersistConfig = {
|
||||
key: 'auth',
|
||||
storage,
|
||||
storage: dynamicStorage,
|
||||
whitelist: ['user', 'tenantId', 'tenant', 'roles', 'permissions', 'accessToken', 'refreshToken', 'tokenType', 'expiresIn', 'expiresAt', 'isAuthenticated'],
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user