This commit is contained in:
Iliyan Angelov
2025-12-01 01:08:39 +02:00
parent 0fa2adeb19
commit 1a103a769f
234 changed files with 5513 additions and 283 deletions

View File

@@ -18,16 +18,21 @@ import {
Eye,
EyeOff,
RefreshCw,
KeyRound
KeyRound,
LogOut,
Monitor
} from 'lucide-react';
import { toast } from 'react-toastify';
import authService from '../../features/auth/services/authService';
import sessionService from '../../features/auth/services/sessionService';
import useAuthStore from '../../store/useAuthStore';
import Loading from '../../shared/components/Loading';
import EmptyState from '../../shared/components/EmptyState';
import { useAsync } from '../../shared/hooks/useAsync';
import { useGlobalLoading } from '../../shared/contexts/LoadingContext';
import { normalizeImageUrl } from '../../shared/utils/imageUtils';
import { formatDate } from '../../shared/utils/format';
import { UserSession } from '../../features/auth/services/sessionService';
const profileValidationSchema = yup.object().shape({
name: yup
@@ -55,7 +60,11 @@ const passwordValidationSchema = yup.object().shape({
newPassword: yup
.string()
.required('New password is required')
.min(6, 'Password must be at least 6 characters'),
.min(8, 'Password must be at least 8 characters')
.matches(/[A-Z]/, 'Password must contain at least one uppercase letter')
.matches(/[a-z]/, 'Password must contain at least one lowercase letter')
.matches(/[0-9]/, 'Password must contain at least one number')
.matches(/[!@#$%^&*(),.?":{}|<>]/, 'Password must contain at least one special character (!@#$%^&*(),.?":{}|<>)'),
confirmPassword: yup
.string()
.required('Please confirm your password')
@@ -68,7 +77,7 @@ type PasswordFormData = yup.InferType<typeof passwordValidationSchema>;
const ProfilePage: React.FC = () => {
const { userInfo, setUser } = useAuthStore();
const { setLoading } = useGlobalLoading();
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'mfa'>('profile');
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'mfa' | 'sessions'>('profile');
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState<{
current: boolean;
@@ -402,6 +411,17 @@ const ProfilePage: React.FC = () => {
<Shield className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2" />
Two-Factor Authentication
</button>
<button
onClick={() => setActiveTab('sessions')}
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-medium text-xs sm:text-sm transition-colors whitespace-nowrap ${
activeTab === 'sessions'
? 'border-[#d4af37] text-[#d4af37]'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Monitor className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2" />
Active Sessions
</button>
</div>
</div>
@@ -967,9 +987,133 @@ const ProfilePage: React.FC = () => {
)}
</div>
)}
{/* Sessions Tab */}
{activeTab === 'sessions' && (
<SessionsTab />
)}
</div>
</div>
);
};
const SessionsTab: React.FC = () => {
const [sessions, setSessions] = useState<UserSession[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchSessions();
}, []);
const fetchSessions = async () => {
try {
setLoading(true);
const response = await sessionService.getMySessions();
setSessions(response.data.sessions || []);
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to load sessions');
} finally {
setLoading(false);
}
};
const handleRevoke = async (sessionId: number) => {
if (!confirm('Are you sure you want to revoke this session?')) return;
try {
await sessionService.revokeSession(sessionId);
toast.success('Session revoked');
fetchSessions();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to revoke session');
}
};
const handleRevokeAll = async () => {
if (!confirm('Are you sure you want to revoke all other sessions?')) return;
try {
await sessionService.revokeAllSessions();
toast.success('All other sessions revoked');
fetchSessions();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Unable to revoke sessions');
}
};
if (loading) {
return (
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl">
<Loading text="Loading sessions..." />
</div>
);
}
return (
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl animate-slide-up space-y-5 sm:space-y-6">
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl sm:text-2xl font-serif font-semibold text-gray-900 mb-2 flex items-center gap-2">
<Monitor className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37]" />
Active Sessions
</h2>
<p className="text-xs sm:text-sm text-gray-600 font-light">
Manage your active sessions across different devices
</p>
</div>
{sessions.length > 1 && (
<button
onClick={handleRevokeAll}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm"
>
Revoke All Others
</button>
)}
</div>
{sessions.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-500">No active sessions</p>
</div>
) : (
<div className="space-y-4">
{sessions.map((session) => (
<div
key={session.id}
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4 flex-1">
<Monitor className="w-5 h-5 text-gray-500 mt-1" />
<div className="flex-1">
<p className="font-semibold mb-1">
{session.user_agent || 'Unknown Device'}
</p>
<p className="text-gray-600 text-sm mb-1">
IP: {session.ip_address || 'Unknown'}
</p>
<p className="text-gray-500 text-xs">
Last Activity: {formatDate(session.last_activity)}
</p>
<p className="text-gray-500 text-xs">
Expires: {formatDate(session.expires_at)}
</p>
</div>
</div>
<button
onClick={() => handleRevoke(session.id)}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 flex items-center gap-2 text-sm"
>
<LogOut className="w-4 h-4" />
Revoke
</button>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default ProfilePage;