1135 lines
46 KiB
TypeScript
1135 lines
46 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import { yupResolver } from '@hookform/resolvers/yup';
|
|
import * as yup from 'yup';
|
|
import {
|
|
User,
|
|
Mail,
|
|
Phone,
|
|
Save,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
Lock,
|
|
Camera,
|
|
Shield,
|
|
ShieldCheck,
|
|
ShieldOff,
|
|
Copy,
|
|
Eye,
|
|
EyeOff,
|
|
RefreshCw,
|
|
KeyRound,
|
|
LogOut,
|
|
Monitor,
|
|
Smartphone,
|
|
Tablet
|
|
} 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
|
|
.string()
|
|
.required('Full name is required')
|
|
.min(2, 'Full name must be at least 2 characters')
|
|
.max(100, 'Full name cannot exceed 100 characters'),
|
|
email: yup
|
|
.string()
|
|
.required('Email is required')
|
|
.email('Invalid email address'),
|
|
phone: yup
|
|
.string()
|
|
.required('Phone number is required')
|
|
.matches(
|
|
/^[\d\s\-\+\(\)]{5,}$/,
|
|
'Please enter a valid phone number'
|
|
),
|
|
});
|
|
|
|
const passwordValidationSchema = yup.object().shape({
|
|
currentPassword: yup
|
|
.string()
|
|
.required('Current password is required'),
|
|
newPassword: yup
|
|
.string()
|
|
.required('New password is required')
|
|
.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')
|
|
.oneOf([yup.ref('newPassword')], 'Passwords must match'),
|
|
});
|
|
|
|
type ProfileFormData = yup.InferType<typeof profileValidationSchema>;
|
|
type PasswordFormData = yup.InferType<typeof passwordValidationSchema>;
|
|
|
|
const HousekeepingProfilePage: React.FC = () => {
|
|
const { userInfo, setUser } = useAuthStore();
|
|
const { setLoading } = useGlobalLoading();
|
|
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'mfa' | 'sessions'>('profile');
|
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
|
const [avatarError, setAvatarError] = useState<boolean>(false);
|
|
const [showPassword, setShowPassword] = useState<{
|
|
current: boolean;
|
|
new: boolean;
|
|
confirm: boolean;
|
|
}>({
|
|
current: false,
|
|
new: false,
|
|
confirm: false,
|
|
});
|
|
|
|
const [mfaStatus, setMfaStatus] = useState<{mfa_enabled: boolean; backup_codes_count: number} | null>(null);
|
|
const [mfaSecret, setMfaSecret] = useState<string | null>(null);
|
|
const [mfaQrCode, setMfaQrCode] = useState<string | null>(null);
|
|
const [mfaVerificationToken, setMfaVerificationToken] = useState<string>('');
|
|
const [showBackupCodes, setShowBackupCodes] = useState<string[] | null>(null);
|
|
const [showMfaSecret, setShowMfaSecret] = useState<boolean>(false);
|
|
const mfaAbortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
const fetchProfile = async () => {
|
|
const response = await authService.getProfile();
|
|
if (response.status === 'success' || response.success) {
|
|
const user = response.data?.user || response.data;
|
|
if (user) {
|
|
setUser(user as any);
|
|
return user;
|
|
}
|
|
}
|
|
throw new Error('Failed to load profile');
|
|
};
|
|
|
|
const {
|
|
data: profileData,
|
|
loading: loadingProfile,
|
|
error: profileError,
|
|
execute: refetchProfile
|
|
} = useAsync(fetchProfile, {
|
|
immediate: true,
|
|
onError: (error: any) => {
|
|
toast.error(error.message || 'Unable to load profile');
|
|
}
|
|
});
|
|
|
|
const {
|
|
register: registerProfile,
|
|
handleSubmit: handleSubmitProfile,
|
|
formState: { errors: profileErrors },
|
|
reset: resetProfile,
|
|
} = useForm<ProfileFormData>({
|
|
resolver: yupResolver(profileValidationSchema),
|
|
defaultValues: {
|
|
name: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.name || '',
|
|
email: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.email || '',
|
|
phone: ((userInfo && 'user' in userInfo ? userInfo.user : userInfo) as any)?.phone || '',
|
|
},
|
|
});
|
|
|
|
const {
|
|
register: registerPassword,
|
|
handleSubmit: handleSubmitPassword,
|
|
formState: { errors: passwordErrors },
|
|
reset: resetPassword,
|
|
} = useForm<PasswordFormData>({
|
|
resolver: yupResolver(passwordValidationSchema),
|
|
});
|
|
|
|
const fetchMFAStatus = async () => {
|
|
try {
|
|
const response = await authService.getMFAStatus();
|
|
if (response) {
|
|
setMfaStatus({
|
|
mfa_enabled: response.mfa_enabled || false,
|
|
backup_codes_count: response.backup_codes_count || 0,
|
|
});
|
|
}
|
|
} catch (error: any) {
|
|
if (error.name === 'AbortError') return;
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (profileData || userInfo) {
|
|
const data = profileData || (userInfo && 'user' in userInfo ? userInfo.user : userInfo);
|
|
resetProfile({
|
|
name: (data as any)?.name || '',
|
|
email: (data as any)?.email || '',
|
|
phone: (data as any)?.phone || '',
|
|
});
|
|
if ((data as any)?.avatar) {
|
|
setAvatarPreview(normalizeImageUrl((data as any).avatar));
|
|
setAvatarError(false);
|
|
} else {
|
|
setAvatarPreview(null);
|
|
setAvatarError(false);
|
|
}
|
|
}
|
|
}, [profileData, userInfo, resetProfile]);
|
|
|
|
useEffect(() => {
|
|
if (activeTab === 'mfa') {
|
|
if (mfaAbortControllerRef.current) {
|
|
mfaAbortControllerRef.current.abort();
|
|
}
|
|
mfaAbortControllerRef.current = new AbortController();
|
|
fetchMFAStatus();
|
|
}
|
|
|
|
return () => {
|
|
if (mfaAbortControllerRef.current) {
|
|
mfaAbortControllerRef.current.abort();
|
|
}
|
|
};
|
|
}, [activeTab]);
|
|
|
|
const onSubmitProfile = async (data: ProfileFormData) => {
|
|
try {
|
|
setLoading(true, 'Updating profile...');
|
|
|
|
if ('updateProfile' in authService) {
|
|
const response = await (authService as any).updateProfile({
|
|
full_name: data.name,
|
|
email: data.email,
|
|
phone_number: data.phone,
|
|
});
|
|
|
|
if (response.status === 'success' || response.success) {
|
|
const updatedUser = response.data?.user || response.data;
|
|
if (updatedUser) {
|
|
setUser(updatedUser);
|
|
toast.success('Profile updated successfully!');
|
|
refetchProfile();
|
|
}
|
|
}
|
|
} else {
|
|
const { updateUser } = await import('../../features/auth/services/userService');
|
|
const user = userInfo && 'user' in userInfo ? userInfo.user : userInfo;
|
|
const response = await updateUser((user as any)?.id || (userInfo as any)?.id, {
|
|
full_name: data.name,
|
|
email: data.email,
|
|
phone_number: data.phone,
|
|
});
|
|
|
|
if ((response as any).success || (response as any).status === 'success') {
|
|
const updatedUser = response.data?.user || response.data;
|
|
if (updatedUser) {
|
|
setUser({
|
|
id: updatedUser.id,
|
|
name: (updatedUser as any).full_name || (updatedUser as any).name,
|
|
email: updatedUser.email,
|
|
phone: (updatedUser as any).phone_number || (updatedUser as any).phone,
|
|
avatar: (updatedUser as any).avatar,
|
|
role: updatedUser.role,
|
|
});
|
|
toast.success('Profile updated successfully!');
|
|
refetchProfile();
|
|
}
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
const errorMessage =
|
|
error.response?.data?.message ||
|
|
error.message ||
|
|
'Failed to update profile';
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const onSubmitPassword = async (data: PasswordFormData) => {
|
|
try {
|
|
setLoading(true, 'Changing password...');
|
|
|
|
if ('updateProfile' in authService) {
|
|
const response = await (authService as any).updateProfile({
|
|
currentPassword: data.currentPassword,
|
|
password: data.newPassword,
|
|
});
|
|
|
|
if (response.status === 'success' || response.success) {
|
|
toast.success('Password changed successfully!');
|
|
resetPassword();
|
|
}
|
|
} else {
|
|
const { updateUser } = await import('../../features/auth/services/userService');
|
|
const user = userInfo && 'user' in userInfo ? userInfo.user : userInfo;
|
|
const response = await updateUser((user as any)?.id || (userInfo as any)?.id, {
|
|
password: data.newPassword,
|
|
});
|
|
|
|
if ((response as any).success || (response as any).status === 'success') {
|
|
toast.success('Password changed successfully!');
|
|
resetPassword();
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
const errorMessage =
|
|
error.response?.data?.message ||
|
|
error.message ||
|
|
'Failed to change password';
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
toast.error('Please select an image file');
|
|
return;
|
|
}
|
|
|
|
if (file.size > 2 * 1024 * 1024) {
|
|
toast.error('Image size must be less than 2MB');
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => {
|
|
setAvatarPreview(reader.result as string);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
|
|
try {
|
|
setLoading(true, 'Uploading avatar...');
|
|
const response = await authService.uploadAvatar(file);
|
|
|
|
if ((response.status === 'success' || response.success) && response.data) {
|
|
const updatedUser = response.data.user || response.data;
|
|
const avatarUrl = (response.data as any).full_url || (response.data as any).avatar_url || normalizeImageUrl((updatedUser as any)?.avatar);
|
|
|
|
if (updatedUser) {
|
|
setUser({
|
|
id: updatedUser.id,
|
|
name: (updatedUser as any).name || (updatedUser as any).full_name,
|
|
email: updatedUser.email,
|
|
phone: (updatedUser as any).phone || (updatedUser as any).phone_number,
|
|
avatar: avatarUrl,
|
|
role: updatedUser.role,
|
|
});
|
|
setAvatarPreview(avatarUrl || null);
|
|
setAvatarError(false);
|
|
toast.success(response.message || 'Avatar uploaded successfully!');
|
|
refetchProfile();
|
|
} else {
|
|
throw new Error('Invalid response format');
|
|
}
|
|
} else {
|
|
throw new Error(response.message || 'Upload failed');
|
|
}
|
|
} catch (error: any) {
|
|
const errorMessage =
|
|
error.response?.data?.detail ||
|
|
error.response?.data?.message ||
|
|
error.message ||
|
|
'Failed to upload avatar. Please try again.';
|
|
toast.error(errorMessage);
|
|
|
|
if (userInfo?.avatar) {
|
|
setAvatarPreview(normalizeImageUrl(userInfo.avatar));
|
|
setAvatarError(true);
|
|
} else {
|
|
setAvatarPreview(null);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
e.target.value = '';
|
|
}
|
|
};
|
|
|
|
if (loadingProfile) {
|
|
return <Loading fullScreen text="Loading profile..." />;
|
|
}
|
|
|
|
if (profileError && !userInfo) {
|
|
return (
|
|
<div className="container mx-auto px-3 sm:px-4 py-4 sm:py-8">
|
|
<EmptyState
|
|
title="Unable to Load Profile"
|
|
description={profileError.message || 'Something went wrong. Please try again.'}
|
|
action={{
|
|
label: 'Retry',
|
|
onClick: refetchProfile
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-slate-100 to-amber-50/30 py-4 sm:py-6 lg:py-8 px-3 sm:px-4 lg:px-8">
|
|
<div className="container mx-auto max-w-6xl">
|
|
{/* Header */}
|
|
<div className="mb-6 sm:mb-8 animate-fade-in">
|
|
<div className="flex items-center gap-2 sm:gap-3 mb-2 sm:mb-3">
|
|
<div className="h-1 w-12 sm:w-16 md:w-20 bg-gradient-to-r from-[#d4af37] via-amber-400 to-[#d4af37] rounded-full"></div>
|
|
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-serif font-bold bg-gradient-to-r from-gray-900 via-amber-900/90 to-gray-900 bg-clip-text text-transparent tracking-tight">
|
|
Housekeeping Profile
|
|
</h1>
|
|
</div>
|
|
<p className="text-sm sm:text-base text-gray-600 font-light tracking-wide ml-0 sm:ml-16">
|
|
Manage your account information and security settings
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="mb-4 sm:mb-6 border-b border-[#d4af37]/20 overflow-x-auto bg-white/50 backdrop-blur-sm rounded-t-lg sm:rounded-t-xl px-4 sm:px-6">
|
|
<div className="flex space-x-4 sm:space-x-8 min-w-max">
|
|
<button
|
|
onClick={() => setActiveTab('profile')}
|
|
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
|
|
activeTab === 'profile'
|
|
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
|
|
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
|
|
}`}
|
|
>
|
|
<User className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'profile' ? 'text-[#d4af37]' : ''}`} />
|
|
Profile
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('password')}
|
|
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
|
|
activeTab === 'password'
|
|
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
|
|
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
|
|
}`}
|
|
>
|
|
<KeyRound className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'password' ? 'text-[#d4af37]' : ''}`} />
|
|
Password
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('mfa')}
|
|
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
|
|
activeTab === 'mfa'
|
|
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
|
|
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
|
|
}`}
|
|
>
|
|
<Shield className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'mfa' ? 'text-[#d4af37]' : ''}`} />
|
|
Two-Factor Auth
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('sessions')}
|
|
className={`py-3 sm:py-4 px-2 sm:px-1 border-b-2 font-semibold text-xs sm:text-sm transition-all duration-300 whitespace-nowrap ${
|
|
activeTab === 'sessions'
|
|
? 'border-[#d4af37] text-[#d4af37] shadow-sm'
|
|
: 'border-transparent text-gray-500 hover:text-[#d4af37] hover:border-[#d4af37]/30'
|
|
}`}
|
|
>
|
|
<Monitor className={`w-4 h-4 sm:w-5 sm:h-5 inline mr-2 ${activeTab === 'sessions' ? 'text-[#d4af37]' : ''}`} />
|
|
Sessions
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Profile Tab */}
|
|
{activeTab === 'profile' && (
|
|
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl animate-slide-up">
|
|
<form onSubmit={handleSubmitProfile(onSubmitProfile)} className="space-y-5 sm:space-y-6">
|
|
{/* Avatar Section */}
|
|
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4 sm:gap-6 pb-5 sm:pb-6 border-b border-gray-200">
|
|
<div className="relative">
|
|
{(avatarPreview || userInfo?.avatar) && !avatarError ? (
|
|
<img
|
|
src={avatarPreview || normalizeImageUrl(userInfo?.avatar)}
|
|
alt="Profile"
|
|
className="w-20 h-20 sm:w-24 sm:h-24 rounded-full object-cover ring-4 ring-[#d4af37]/20 shadow-lg"
|
|
onError={() => setAvatarError(true)}
|
|
/>
|
|
) : (
|
|
<div className="w-20 h-20 sm:w-24 sm:h-24 rounded-full bg-gradient-to-br from-[#d4af37] to-[#c9a227] flex items-center justify-center ring-4 ring-[#d4af37]/20 shadow-lg">
|
|
<User className="w-10 h-10 sm:w-12 sm:h-12 text-white" />
|
|
</div>
|
|
)}
|
|
<label
|
|
htmlFor="avatar-upload"
|
|
className="absolute bottom-0 right-0 p-2 bg-gradient-to-br from-[#d4af37] to-[#c9a227] rounded-full cursor-pointer hover:from-[#f5d76e] hover:to-[#d4af37] transition-all duration-300 shadow-lg"
|
|
>
|
|
<Camera className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-white" />
|
|
<input
|
|
id="avatar-upload"
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleAvatarChange}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Name Field */}
|
|
<div>
|
|
<label className="block text-sm sm:text-base font-medium text-gray-700 mb-2">
|
|
<User className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2 text-[#d4af37]" />
|
|
Full Name <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
{...registerProfile('name')}
|
|
type="text"
|
|
className={`luxury-input text-sm sm:text-base ${
|
|
profileErrors.name ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : ''
|
|
}`}
|
|
placeholder="Enter your full name"
|
|
/>
|
|
{profileErrors.name && (
|
|
<p className="mt-1.5 text-xs sm:text-sm text-red-600 font-light flex items-center gap-1.5">
|
|
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
{profileErrors.name.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Email Field */}
|
|
<div>
|
|
<label className="block text-sm sm:text-base font-medium text-gray-700 mb-2">
|
|
<Mail className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2 text-[#d4af37]" />
|
|
Email Address <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
{...registerProfile('email')}
|
|
type="email"
|
|
className={`luxury-input text-sm sm:text-base ${
|
|
profileErrors.email ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : ''
|
|
}`}
|
|
placeholder="Enter your email"
|
|
/>
|
|
{profileErrors.email && (
|
|
<p className="mt-1.5 text-xs sm:text-sm text-red-600 font-light flex items-center gap-1.5">
|
|
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
{profileErrors.email.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Phone Field */}
|
|
<div>
|
|
<label className="block text-sm sm:text-base font-medium text-gray-700 mb-2">
|
|
<Phone className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2 text-[#d4af37]" />
|
|
Phone Number <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
{...registerProfile('phone')}
|
|
type="tel"
|
|
className={`luxury-input text-sm sm:text-base ${
|
|
profileErrors.phone ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : ''
|
|
}`}
|
|
placeholder="Enter your phone number"
|
|
/>
|
|
{profileErrors.phone && (
|
|
<p className="mt-1.5 text-xs sm:text-sm text-red-600 font-light flex items-center gap-1.5">
|
|
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
{profileErrors.phone.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<div className="flex justify-end pt-4 sm:pt-5 border-t border-gray-200">
|
|
<button
|
|
type="submit"
|
|
className="btn-luxury-primary flex items-center space-x-2 px-4 sm:px-6 py-2.5 sm:py-3 rounded-sm font-semibold text-xs sm:text-sm"
|
|
>
|
|
<Save className="w-4 h-4 sm:w-5 sm:h-5" />
|
|
<span>Save Changes</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* Password Tab */}
|
|
{activeTab === 'password' && (
|
|
<div className="luxury-glass rounded-sm p-4 sm:p-6 lg:p-8 border border-[#d4af37]/20 shadow-2xl animate-slide-up">
|
|
<form onSubmit={handleSubmitPassword(onSubmitPassword)} className="space-y-5 sm:space-y-6">
|
|
{/* Password Requirements */}
|
|
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200/60 rounded-sm p-4 sm:p-5">
|
|
<div className="flex items-start gap-3">
|
|
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-full flex-shrink-0">
|
|
<KeyRound className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<h4 className="text-sm sm:text-base font-semibold text-blue-900 mb-1.5">Password Requirements</h4>
|
|
<ul className="text-xs sm:text-sm text-blue-700 space-y-1 font-light">
|
|
<li>• At least 8 characters long</li>
|
|
<li>• Contains uppercase and lowercase letters</li>
|
|
<li>• Contains at least one number</li>
|
|
<li>• Contains at least one special character</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Current Password */}
|
|
<div>
|
|
<label className="block text-sm sm:text-base font-medium text-gray-700 mb-2">
|
|
<Lock className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2 text-[#d4af37]" />
|
|
Current Password <span className="text-red-500">*</span>
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
{...registerPassword('currentPassword')}
|
|
type={showPassword.current ? 'text' : 'password'}
|
|
className={`luxury-input text-sm sm:text-base pr-10 sm:pr-12 ${
|
|
passwordErrors.currentPassword ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : ''
|
|
}`}
|
|
placeholder="Enter your current password"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword({ ...showPassword, current: !showPassword.current })}
|
|
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-[#d4af37] transition-colors"
|
|
>
|
|
{showPassword.current ? <EyeOff className="w-4 h-4 sm:w-5 sm:h-5" /> : <Eye className="w-4 h-4 sm:w-5 sm:h-5" />}
|
|
</button>
|
|
</div>
|
|
{passwordErrors.currentPassword && (
|
|
<p className="mt-1.5 text-xs sm:text-sm text-red-600 font-light flex items-center gap-1.5">
|
|
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
{passwordErrors.currentPassword.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* New Password */}
|
|
<div>
|
|
<label className="block text-sm sm:text-base font-medium text-gray-700 mb-2">
|
|
<Lock className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2 text-[#d4af37]" />
|
|
New Password <span className="text-red-500">*</span>
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
{...registerPassword('newPassword')}
|
|
type={showPassword.new ? 'text' : 'password'}
|
|
className={`luxury-input text-sm sm:text-base pr-10 sm:pr-12 ${
|
|
passwordErrors.newPassword ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : ''
|
|
}`}
|
|
placeholder="Enter your new password"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword({ ...showPassword, new: !showPassword.new })}
|
|
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-[#d4af37] transition-colors"
|
|
>
|
|
{showPassword.new ? <EyeOff className="w-4 h-4 sm:w-5 sm:h-5" /> : <Eye className="w-4 h-4 sm:w-5 sm:h-5" />}
|
|
</button>
|
|
</div>
|
|
{passwordErrors.newPassword && (
|
|
<p className="mt-1.5 text-xs sm:text-sm text-red-600 font-light flex items-center gap-1.5">
|
|
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
{passwordErrors.newPassword.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Confirm Password */}
|
|
<div>
|
|
<label className="block text-sm sm:text-base font-medium text-gray-700 mb-2">
|
|
<Lock className="w-4 h-4 sm:w-5 sm:h-5 inline mr-2 text-[#d4af37]" />
|
|
Confirm Password <span className="text-red-500">*</span>
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
{...registerPassword('confirmPassword')}
|
|
type={showPassword.confirm ? 'text' : 'password'}
|
|
className={`luxury-input text-sm sm:text-base pr-10 sm:pr-12 ${
|
|
passwordErrors.confirmPassword ? 'border-red-300 focus:ring-red-500 focus:border-red-500' : ''
|
|
}`}
|
|
placeholder="Confirm your new password"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword({ ...showPassword, confirm: !showPassword.confirm })}
|
|
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-[#d4af37] transition-colors"
|
|
>
|
|
{showPassword.confirm ? <EyeOff className="w-4 h-4 sm:w-5 sm:h-5" /> : <Eye className="w-4 h-4 sm:w-5 sm:h-5" />}
|
|
</button>
|
|
</div>
|
|
{passwordErrors.confirmPassword && (
|
|
<p className="mt-1.5 text-xs sm:text-sm text-red-600 font-light flex items-center gap-1.5">
|
|
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
|
{passwordErrors.confirmPassword.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<div className="flex justify-end pt-4 sm:pt-5 border-t border-gray-200">
|
|
<button
|
|
type="submit"
|
|
className="btn-luxury-primary flex items-center space-x-2 px-4 sm:px-6 py-2.5 sm:py-3 rounded-sm font-semibold text-xs sm:text-sm"
|
|
>
|
|
<Lock className="w-4 h-4 sm:w-5 sm:h-5" />
|
|
<span>Change Password</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* MFA Tab */}
|
|
{activeTab === 'mfa' && (
|
|
<MFATab
|
|
mfaStatus={mfaStatus}
|
|
mfaSecret={mfaSecret}
|
|
mfaQrCode={mfaQrCode}
|
|
mfaVerificationToken={mfaVerificationToken}
|
|
showBackupCodes={showBackupCodes}
|
|
showMfaSecret={showMfaSecret}
|
|
setMfaSecret={setMfaSecret}
|
|
setMfaQrCode={setMfaQrCode}
|
|
setMfaVerificationToken={setMfaVerificationToken}
|
|
setShowBackupCodes={setShowBackupCodes}
|
|
setShowMfaSecret={setShowMfaSecret}
|
|
fetchMFAStatus={fetchMFAStatus}
|
|
setLoading={setLoading}
|
|
/>
|
|
)}
|
|
|
|
{/* Sessions Tab */}
|
|
{activeTab === 'sessions' && <SessionsTab />}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// MFA Tab Component
|
|
interface MFATabProps {
|
|
mfaStatus: {mfa_enabled: boolean; backup_codes_count: number} | null;
|
|
mfaSecret: string | null;
|
|
mfaQrCode: string | null;
|
|
mfaVerificationToken: string;
|
|
showBackupCodes: string[] | null;
|
|
showMfaSecret: boolean;
|
|
setMfaSecret: (value: string | null) => void;
|
|
setMfaQrCode: (value: string | null) => void;
|
|
setMfaVerificationToken: (value: string) => void;
|
|
setShowBackupCodes: (value: string[] | null) => void;
|
|
setShowMfaSecret: (value: boolean) => void;
|
|
fetchMFAStatus: () => Promise<void>;
|
|
setLoading: (loading: boolean, message?: string) => void;
|
|
}
|
|
|
|
const MFATab: React.FC<MFATabProps> = ({
|
|
mfaStatus,
|
|
mfaSecret,
|
|
mfaQrCode,
|
|
mfaVerificationToken,
|
|
showBackupCodes,
|
|
showMfaSecret,
|
|
setMfaSecret,
|
|
setMfaQrCode,
|
|
setMfaVerificationToken,
|
|
setShowBackupCodes,
|
|
setShowMfaSecret,
|
|
fetchMFAStatus,
|
|
setLoading,
|
|
}) => {
|
|
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>
|
|
<h2 className="text-xl sm:text-2xl font-serif font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
|
<Shield className="w-5 h-5 sm:w-6 sm:h-6 text-[#d4af37]" />
|
|
Two-Factor Authentication
|
|
</h2>
|
|
<p className="text-xs sm:text-sm text-gray-600 font-light">
|
|
Add extra security to your account with MFA.
|
|
</p>
|
|
</div>
|
|
|
|
{mfaStatus?.mfa_enabled ? (
|
|
<div className="space-y-5">
|
|
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200/60 rounded-sm p-4 sm:p-5">
|
|
<div className="flex items-start gap-3">
|
|
<ShieldCheck className="w-6 h-6 text-green-600 flex-shrink-0" />
|
|
<div>
|
|
<h3 className="font-semibold text-green-900">MFA is Enabled</h3>
|
|
<p className="text-sm text-green-700">Your account is protected with two-factor authentication.</p>
|
|
<p className="text-xs text-green-600 mt-1">Remaining backup codes: {mfaStatus.backup_codes_count}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
setLoading(true, 'Regenerating backup codes...');
|
|
const response = await authService.regenerateBackupCodes();
|
|
if (response.status === 'success' && response.data?.backup_codes) {
|
|
setShowBackupCodes(response.data.backup_codes);
|
|
await fetchMFAStatus();
|
|
toast.success('Backup codes regenerated!');
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Failed to regenerate backup codes');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-amber-50 border border-amber-200 rounded-sm text-amber-900 hover:bg-amber-100 transition-colors"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Regenerate Backup Codes
|
|
</button>
|
|
|
|
<button
|
|
onClick={async () => {
|
|
if (!window.confirm('Are you sure you want to disable MFA?')) return;
|
|
try {
|
|
setLoading(true, 'Disabling MFA...');
|
|
const response = await authService.disableMFA();
|
|
if (response.status === 'success') {
|
|
await fetchMFAStatus();
|
|
toast.success('MFA disabled');
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Failed to disable MFA');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-red-50 border border-red-200 rounded-sm text-red-900 hover:bg-red-100 transition-colors"
|
|
>
|
|
<ShieldOff className="w-4 h-4" />
|
|
Disable MFA
|
|
</button>
|
|
|
|
{showBackupCodes && (
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-sm p-4">
|
|
<h4 className="font-semibold text-yellow-900 mb-2">Save Your Backup Codes</h4>
|
|
<div className="grid grid-cols-2 gap-2 mb-3">
|
|
{showBackupCodes.map((code, index) => (
|
|
<div key={index} className="bg-white border border-yellow-300 rounded px-3 py-2 font-mono text-sm flex items-center justify-between">
|
|
<span>{code}</span>
|
|
<button
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(code);
|
|
toast.success('Copied!');
|
|
}}
|
|
className="text-yellow-600 hover:text-yellow-800"
|
|
>
|
|
<Copy className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button onClick={() => setShowBackupCodes(null)} className="text-sm text-yellow-700 underline">
|
|
I've saved these codes
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-5">
|
|
{!mfaSecret ? (
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
setLoading(true, 'Initializing MFA...');
|
|
const response = await authService.initMFA();
|
|
if (response.status === 'success' && response.data) {
|
|
setMfaSecret(response.data.secret || '');
|
|
setMfaQrCode(response.data.qr_code || '');
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Failed to initialize MFA');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}}
|
|
className="btn-luxury-primary flex items-center gap-2 px-6 py-3 rounded-sm font-semibold"
|
|
>
|
|
<Shield className="w-5 h-5" />
|
|
Enable Two-Factor Authentication
|
|
</button>
|
|
) : (
|
|
<div className="space-y-5">
|
|
<div className="bg-blue-50 border border-blue-200 rounded-sm p-4">
|
|
<h3 className="font-semibold text-blue-900 mb-2">Step 1: Scan QR Code</h3>
|
|
<p className="text-sm text-blue-700 mb-4">Use your authenticator app to scan this QR code.</p>
|
|
{mfaQrCode && (
|
|
<div className="flex justify-center mb-4">
|
|
<img src={mfaQrCode} alt="MFA QR Code" className="border-4 border-white rounded-lg shadow-lg max-w-[200px]" />
|
|
</div>
|
|
)}
|
|
<div className="bg-white border border-blue-200 rounded p-3">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium text-gray-700">Manual Entry Key:</span>
|
|
<button onClick={() => setShowMfaSecret(!showMfaSecret)} className="text-blue-600 hover:text-blue-800">
|
|
{showMfaSecret ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 font-mono text-sm bg-gray-50 p-2 rounded border text-gray-900 break-all">
|
|
{showMfaSecret ? mfaSecret : '••••••••••••••••'}
|
|
</code>
|
|
<button
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(mfaSecret || '');
|
|
toast.success('Copied!');
|
|
}}
|
|
className="px-3 py-2 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
|
>
|
|
<Copy className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white border border-gray-200 rounded-sm p-4">
|
|
<h3 className="font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
|
<KeyRound className="w-4 h-4 text-[#d4af37]" />
|
|
Step 2: Verify Setup
|
|
</h3>
|
|
<p className="text-sm text-gray-600 mb-4">Enter the 6-digit code from your authenticator app.</p>
|
|
<div className="space-y-4">
|
|
<input
|
|
type="text"
|
|
value={mfaVerificationToken}
|
|
onChange={(e) => setMfaVerificationToken(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
|
placeholder="000000"
|
|
maxLength={6}
|
|
className="luxury-input text-center text-xl tracking-widest font-mono"
|
|
/>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={async () => {
|
|
if (!mfaVerificationToken || mfaVerificationToken.length !== 6) {
|
|
toast.error('Please enter a valid 6-digit code');
|
|
return;
|
|
}
|
|
try {
|
|
setLoading(true, 'Enabling MFA...');
|
|
const response = await authService.enableMFA(mfaSecret || '', mfaVerificationToken);
|
|
if (response.status === 'success' && response.data?.backup_codes) {
|
|
setShowBackupCodes(response.data.backup_codes);
|
|
await fetchMFAStatus();
|
|
setMfaSecret(null);
|
|
setMfaQrCode(null);
|
|
setMfaVerificationToken('');
|
|
toast.success('MFA enabled!');
|
|
}
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.detail || 'Invalid verification code');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}}
|
|
className="btn-luxury-primary flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-sm font-semibold"
|
|
>
|
|
<ShieldCheck className="w-4 h-4" />
|
|
Verify & Enable
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setMfaSecret(null);
|
|
setMfaQrCode(null);
|
|
setMfaVerificationToken('');
|
|
setShowMfaSecret(false);
|
|
}}
|
|
className="px-4 py-2.5 border border-gray-300 text-gray-700 rounded-sm hover:bg-gray-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Sessions Tab Component
|
|
const SessionsTab: React.FC = () => {
|
|
const [sessions, setSessions] = useState<UserSession[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
abortControllerRef.current = new AbortController();
|
|
fetchSessions();
|
|
|
|
return () => {
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
const fetchSessions = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await sessionService.getMySessions();
|
|
setSessions(response.data?.sessions || []);
|
|
} catch (error: any) {
|
|
if (error.name === 'AbortError') return;
|
|
toast.error(error.response?.data?.message || 'Unable to load sessions');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const getDeviceIcon = (userAgent?: string) => {
|
|
if (!userAgent) return <Monitor className="w-5 h-5 text-[#d4af37]" />;
|
|
if (userAgent.includes('Mobile')) return <Smartphone className="w-5 h-5 text-blue-500" />;
|
|
if (userAgent.includes('Tablet')) return <Tablet className="w-5 h-5 text-purple-500" />;
|
|
return <Monitor className="w-5 h-5 text-[#d4af37]" />;
|
|
};
|
|
|
|
const getDeviceName = (userAgent?: string) => {
|
|
if (!userAgent) return 'Unknown Device';
|
|
if (userAgent.includes('Chrome')) return 'Chrome Browser';
|
|
if (userAgent.includes('Firefox')) return 'Firefox Browser';
|
|
if (userAgent.includes('Safari')) return 'Safari Browser';
|
|
if (userAgent.includes('Edge')) return 'Edge Browser';
|
|
if (userAgent.includes('Mobile')) return 'Mobile Device';
|
|
if (userAgent.includes('Tablet')) return 'Tablet Device';
|
|
return 'Web Browser';
|
|
};
|
|
|
|
const handleRevoke = async (sessionId: number) => {
|
|
if (!confirm('Are you sure you want to revoke this session?')) return;
|
|
try {
|
|
const response = await sessionService.revokeSession(sessionId);
|
|
if (response.data?.logout_required) {
|
|
toast.warning('Your current session has been revoked. You will be logged out.');
|
|
setTimeout(() => { window.location.href = '/'; }, 2000);
|
|
} else {
|
|
toast.success('Session revoked successfully');
|
|
fetchSessions();
|
|
}
|
|
} catch (error: any) {
|
|
if (error.response?.status === 401) {
|
|
toast.warning('Your session has been revoked. You will be logged out.');
|
|
setTimeout(() => { window.location.href = '/'; }, 2000);
|
|
} else {
|
|
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 {
|
|
const response = await sessionService.revokeAllSessions();
|
|
if (response.data?.logout_required) {
|
|
toast.warning('All sessions have been revoked. You will be logged out.');
|
|
setTimeout(() => { window.location.href = '/'; }, 2000);
|
|
} else {
|
|
toast.success('All sessions revoked');
|
|
fetchSessions();
|
|
}
|
|
} catch (error: any) {
|
|
if (error.response?.status === 401) {
|
|
toast.warning('All sessions have been revoked. You will be logged out.');
|
|
setTimeout(() => { window.location.href = '/'; }, 2000);
|
|
} else {
|
|
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>
|
|
<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 === 0 ? (
|
|
<div className="text-center py-12">
|
|
<Monitor className="w-12 h-12 mx-auto text-gray-400 mb-3" />
|
|
<p className="text-gray-500 text-sm font-light">No active sessions</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{sessions.length > 1 && (
|
|
<div className="flex justify-end pb-2">
|
|
<button
|
|
onClick={handleRevokeAll}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-sm font-semibold hover:bg-red-700 shadow-lg transition-all text-xs sm:text-sm flex items-center gap-2"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
Revoke All Other Sessions
|
|
</button>
|
|
</div>
|
|
)}
|
|
{sessions.map((session) => (
|
|
<div
|
|
key={session.id}
|
|
className="bg-gradient-to-r from-slate-50 to-white border border-[#d4af37]/20 rounded-sm p-4 sm:p-5 hover:shadow-lg transition-all"
|
|
>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex items-start gap-4 flex-1">
|
|
<div className="p-2 sm:p-3 bg-gradient-to-br from-[#d4af37]/10 to-[#c9a227]/10 rounded-sm flex-shrink-0">
|
|
{getDeviceIcon(session.user_agent)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-semibold text-sm sm:text-base text-gray-900 mb-2">
|
|
{getDeviceName(session.user_agent)}
|
|
</p>
|
|
<p className="text-gray-600 text-xs sm:text-sm mb-1">
|
|
IP Address: <span className="font-mono">{session.ip_address || 'Unknown'}</span>
|
|
</p>
|
|
<p className="text-gray-500 text-xs mb-1">
|
|
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-sm hover:bg-red-700 shadow-md transition-all flex items-center gap-2 text-xs sm:text-sm font-semibold flex-shrink-0"
|
|
>
|
|
<LogOut className="w-4 h-4" />
|
|
Revoke
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default HousekeepingProfilePage;
|
|
|