feat: implement session management with dynamic storage and profile device tracking

This commit is contained in:
SibarchanNayak 2026-07-20 18:01:52 +05:30
parent ae654c41fd
commit 2a2798cd0f
9 changed files with 608 additions and 10 deletions

View File

@ -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>
);
};

View 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>
);
};

View File

@ -46,3 +46,5 @@ export { FormTagInput } from './FormTagInput';
export { MarkdownViewer } from './MarkdownViewer';
export { GradientStatCard } from './GradientStatCard';
export { MoreFilters } from './MoreFilters';
export { ProfileModal } from './ProfileModal';

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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;
},
};

View File

@ -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;

View File

@ -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'],
};