Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| import { useState, useEffect, useRef } from 'react'; | |
| import { useAuth } from '../components/AuthContext'; | |
| import { getInitials } from '../components/Layout'; | |
| import { toast } from 'react-hot-toast'; | |
| export const Profile = () => { | |
| const { token, logout } = useAuth(); | |
| const fileInputRef = useRef(null); | |
| const [profile, setProfile] = useState(null); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState(null); | |
| const [reportLogoUrl, setReportLogoUrl] = useState(''); | |
| useEffect(() => { | |
| const fetchProfile = async () => { | |
| try { | |
| const res = await fetch('/api/auth/profile', { | |
| headers: { 'Authorization': `Bearer ${token}` } | |
| }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setProfile(data.user); | |
| } else { | |
| setError("Failed to fetch profile data. Please try again."); | |
| } | |
| } catch (err) { | |
| setError("Network error while fetching profile data."); | |
| console.error(err); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const fetchBranding = async () => { | |
| try { | |
| const res = await fetch('/api/auth/organizations/webhook', { | |
| headers: { 'Authorization': `Bearer ${token}` } | |
| }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setReportLogoUrl(data.report_logo_url || ''); | |
| } | |
| } catch (err) { | |
| console.error("Error loading branding info", err); | |
| } | |
| }; | |
| fetchProfile(); | |
| fetchBranding(); | |
| }, [token]); | |
| const fetchBrandingManual = async () => { | |
| try { | |
| const res = await fetch('/api/auth/organizations/webhook', { | |
| headers: { 'Authorization': `Bearer ${token}` } | |
| }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setReportLogoUrl(data.report_logo_url || ''); | |
| } | |
| } catch (err) { | |
| console.error("Error loading branding info", err); | |
| } | |
| }; | |
| useEffect(() => { | |
| const preventWindowDrop = (e) => { | |
| e.preventDefault(); | |
| }; | |
| window.addEventListener('dragover', preventWindowDrop); | |
| window.addEventListener('drop', preventWindowDrop); | |
| return () => { | |
| window.removeEventListener('dragover', preventWindowDrop); | |
| window.removeEventListener('drop', preventWindowDrop); | |
| }; | |
| }, []); | |
| const [isDragging, setIsDragging] = useState(false); | |
| const [uploadingLogo, setUploadingLogo] = useState(false); | |
| const handleUploadLogoUrl = async (imageUrl) => { | |
| setUploadingLogo(true); | |
| try { | |
| const res = await fetch('/api/auth/organizations/logo', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${token}`, | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ logo_url: imageUrl }) | |
| }); | |
| if (res.ok) { | |
| toast.success("Report branding updated! Future PDF reports will include your logo."); | |
| fetchBrandingManual(); | |
| } else { | |
| const data = await res.json(); | |
| toast.error(data.message || "Failed to download and process web image."); | |
| } | |
| } catch (err) { | |
| toast.error("Error uploading logo URL."); | |
| } finally { | |
| setUploadingLogo(false); | |
| } | |
| }; | |
| const handleUploadLogoFile = async (file) => { | |
| if (!file) return; | |
| if (file.size > 5 * 1024 * 1024) { | |
| toast.error("File size exceeds 5MB limit."); | |
| return; | |
| } | |
| setUploadingLogo(true); | |
| const formData = new FormData(); | |
| formData.append('logo', file); | |
| try { | |
| const res = await fetch('/api/auth/organizations/logo', { | |
| method: 'POST', | |
| headers: { 'Authorization': `Bearer ${token}` }, | |
| body: formData | |
| }); | |
| const data = await res.json(); | |
| if (res.ok) { | |
| if (data.report_logo_url) { | |
| setReportLogoUrl(data.report_logo_url); | |
| } | |
| toast.success("Report branding updated! Future PDF reports will include your logo."); | |
| fetchBrandingManual(); | |
| } else { | |
| toast.error(data.message || "Failed to update report branding."); | |
| } | |
| } catch (err) { | |
| toast.error("Error uploading logo."); | |
| } finally { | |
| setUploadingLogo(false); | |
| } | |
| }; | |
| const handleRemoveLogo = async () => { | |
| try { | |
| const res = await fetch('/api/auth/organizations/logo', { | |
| method: 'DELETE', | |
| headers: { 'Authorization': `Bearer ${token}` } | |
| }); | |
| if (res.ok) { | |
| setReportLogoUrl(''); | |
| toast.success("Logo removed successfully."); | |
| fetchBrandingManual(); | |
| } else { | |
| toast.error("Failed to remove logo."); | |
| } | |
| } catch (err) { | |
| toast.error("Error removing logo."); | |
| } | |
| }; | |
| const handleDragOver = (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (e.dataTransfer) { | |
| e.dataTransfer.dropEffect = 'copy'; | |
| } | |
| if (!isDragging) setIsDragging(true); | |
| }; | |
| const handleDragLeave = (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| // Only set false if leaving the main drop container | |
| if (e.currentTarget && e.relatedTarget && e.currentTarget.contains(e.relatedTarget)) { | |
| return; | |
| } | |
| setIsDragging(false); | |
| }; | |
| const handleDrop = async (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setIsDragging(false); | |
| // 1. Direct File Drop (from File Explorer or Desktop) | |
| if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { | |
| const file = e.dataTransfer.files[0]; | |
| if (file && (file.type.startsWith('image/') || file.type === '' || file.name.match(/\.(png|jpe?g|webp|svg|gif|bmp)$/i))) { | |
| await handleUploadLogoFile(file); | |
| return; | |
| } | |
| } | |
| // 2. DataTransfer Items (Dragging image element or file item from browser window) | |
| if (e.dataTransfer.items && e.dataTransfer.items.length > 0) { | |
| for (let i = 0; i < e.dataTransfer.items.length; i++) { | |
| const item = e.dataTransfer.items[i]; | |
| if (item.kind === 'file') { | |
| const file = item.getAsFile(); | |
| if (file && (file.type.startsWith('image/') || file.name.match(/\.(png|jpe?g|webp|svg|gif|bmp)$/i))) { | |
| await handleUploadLogoFile(file); | |
| return; | |
| } | |
| } | |
| } | |
| } | |
| // 3. Chrome / Web Image / HTML / URL Drag | |
| const htmlData = e.dataTransfer.getData('text/html'); | |
| const uriData = e.dataTransfer.getData('text/uri-list') || e.dataTransfer.getData('URL') || e.dataTransfer.getData('text/plain'); | |
| let imageUrl = ''; | |
| if (htmlData) { | |
| try { | |
| const parser = new DOMParser(); | |
| const doc = parser.parseFromString(htmlData, 'text/html'); | |
| const img = doc.querySelector('img'); | |
| if (img && img.src) { | |
| imageUrl = img.src; | |
| } | |
| } catch (err) { | |
| console.warn("Could not parse dragged HTML", err); | |
| } | |
| } | |
| if (!imageUrl && uriData && uriData.trim().match(/^https?:\/\/.+/i)) { | |
| imageUrl = uriData.trim(); | |
| } | |
| if (imageUrl) { | |
| // Base64 Data URL handling | |
| if (imageUrl.startsWith('data:image/')) { | |
| try { | |
| const arr = imageUrl.split(','); | |
| const mime = arr[0].match(/:(.*?);/)[1]; | |
| const bstr = atob(arr[1]); | |
| let n = bstr.length; | |
| const u8arr = new Uint8Array(n); | |
| while (n--) { | |
| u8arr[n] = bstr.charCodeAt(n); | |
| } | |
| const file = new File([u8arr], 'dragged_logo.png', { type: mime }); | |
| await handleUploadLogoFile(file); | |
| } catch (err) { | |
| toast.error("Invalid base64 image data."); | |
| } | |
| return; | |
| } | |
| // Web HTTP/HTTPS URL handling - First try frontend fetch, fallback to backend fetch | |
| setUploadingLogo(true); | |
| try { | |
| const res = await fetch(imageUrl, { mode: 'cors' }); | |
| if (!res.ok) throw new Error("CORS or HTTP error"); | |
| const blob = await res.blob(); | |
| const contentType = blob.type || 'image/png'; | |
| const fileExt = contentType.split('/')[1] || 'png'; | |
| const file = new File([blob], `dragged_logo.${fileExt}`, { type: contentType }); | |
| await handleUploadLogoFile(file); | |
| } catch (frontendErr) { | |
| // Fallback: send web image URL to backend to download server-side (bypasses CORS!) | |
| await handleUploadLogoUrl(imageUrl); | |
| } finally { | |
| setUploadingLogo(false); | |
| } | |
| return; | |
| } | |
| toast.error("Please drop a valid image file (PNG, JPG, WebP, SVG)."); | |
| }; | |
| const [passwordData, setPasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' }); | |
| const [passwordStatus, setPasswordStatus] = useState({ loading: false, error: null, success: false }); | |
| const [showPassword, setShowPassword] = useState({ current: false, new: false, confirm: false }); | |
| const [showEditModal, setShowEditModal] = useState(false); | |
| const [editData, setEditData] = useState({ first_name: '', last_name: '', email: '', contact_no: '', org_name: '' }); | |
| const [editStatus, setEditStatus] = useState({ loading: false, error: null }); | |
| const handleEditOpen = () => { | |
| setEditData({ | |
| first_name: profile.first_name || '', | |
| last_name: profile.last_name || '', | |
| email: profile.email || '', | |
| contact_no: profile.contact_no || '', | |
| org_name: profile.org_name || 'LarShield Organization' | |
| }); | |
| setShowEditModal(true); | |
| }; | |
| const handleEditSubmit = async (e) => { | |
| e.preventDefault(); | |
| setEditStatus({ loading: true, error: null }); | |
| try { | |
| // Update User Profile | |
| const userRes = await fetch(`/api/auth/users/${profile.id}`, { | |
| method: 'PUT', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| body: JSON.stringify({ | |
| ...profile, | |
| first_name: editData.first_name, | |
| last_name: editData.last_name, | |
| email: editData.email, | |
| contact_no: editData.contact_no | |
| }) | |
| }); | |
| const userData = await userRes.json(); | |
| if (!userRes.ok) { | |
| setEditStatus({ loading: false, error: userData.message || "Failed to update profile" }); | |
| toast.error(userData.message || "Failed to update profile"); | |
| return; | |
| } | |
| // Update Organization Name if changed and user has permission | |
| if (editData.org_name !== profile.org_name && (profile.role === 'org_admin' || profile.role === 'super_admin')) { | |
| const orgRes = await fetch(`/api/auth/organizations/${profile.org_id}`, { | |
| method: 'PUT', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| body: JSON.stringify({ name: editData.org_name }) | |
| }); | |
| if (!orgRes.ok) { | |
| const orgData = await orgRes.json(); | |
| toast.error(orgData.message || "Failed to update organization name"); | |
| } | |
| } | |
| toast.success("Profile updated successfully!"); | |
| setProfile({ | |
| ...profile, | |
| first_name: editData.first_name, | |
| last_name: editData.last_name, | |
| email: editData.email, | |
| contact_no: editData.contact_no, | |
| org_name: editData.org_name | |
| }); | |
| setShowEditModal(false); | |
| setEditStatus({ loading: false, error: null }); | |
| } catch (err) { | |
| setEditStatus({ loading: false, error: "Network error" }); | |
| toast.error("Network error"); | |
| } | |
| }; | |
| const handlePasswordChange = async (e) => { | |
| e.preventDefault(); | |
| setPasswordStatus({ loading: true, error: null, success: false }); | |
| if (passwordData.newPassword !== passwordData.confirmPassword) { | |
| setPasswordStatus({ loading: false, error: "New passwords do not match", success: false }); | |
| return; | |
| } | |
| if (passwordData.newPassword.length < 6) { | |
| setPasswordStatus({ loading: false, error: "New password must be at least 6 characters", success: false }); | |
| return; | |
| } | |
| try { | |
| const res = await fetch('/api/auth/password', { | |
| method: 'PUT', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| body: JSON.stringify({ | |
| currentPassword: passwordData.currentPassword, | |
| newPassword: passwordData.newPassword | |
| }) | |
| }); | |
| let data = {}; | |
| try { | |
| data = await res.json(); | |
| } catch (e) { | |
| if (res.status === 429) { | |
| data = { message: "Too many attempts. Please try again later." }; | |
| } else { | |
| data = { message: "Unexpected server error occurred." }; | |
| } | |
| } | |
| if (res.ok) { | |
| setPasswordStatus({ loading: false, error: null, success: true }); | |
| setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' }); | |
| toast.success("Password updated successfully!"); | |
| setTimeout(() => setPasswordStatus(prev => ({ ...prev, success: false })), 3000); | |
| } else { | |
| const errorMsg = data.message || "Failed to update password"; | |
| setPasswordStatus({ loading: false, error: errorMsg, success: false }); | |
| toast.error(errorMsg); | |
| } | |
| } catch (err) { | |
| setPasswordStatus({ loading: false, error: "Network error occurred", success: false }); | |
| toast.error("Network error occurred"); | |
| } | |
| }; | |
| if (loading) { | |
| return ( | |
| <div className="flex items-center justify-center py-2xl font-label-md text-label-md text-on-surface-variant"> | |
| <span className="material-symbols-outlined animate-spin mr-sm">sync</span> | |
| Loading Profile Data... | |
| </div> | |
| ); | |
| } | |
| if (error || !profile) { | |
| return ( | |
| <div className="text-center py-2xl bg-surface-container-lowest border border-outline-variant rounded-xl max-w-lg mx-auto p-xl flex flex-col items-center gap-md"> | |
| <span className="material-symbols-outlined text-[48px] text-error">error</span> | |
| <h2 className="font-headline-md text-on-surface">Unable to load profile</h2> | |
| <p className="font-body-md text-on-surface-variant">{error || "Profile data not found."}</p> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="flex flex-col gap-6 text-left w-full max-w-7xl mx-auto pb-8"> | |
| {/* Main Grid Layout - 3 Equal Columns */} | |
| <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch"> | |
| {/* Card 1: Identity Card */} | |
| <div className="bg-surface-container-lowest border border-outline-variant rounded-lg shadow-sm p-6 flex flex-col h-full"> | |
| <div className="flex items-center justify-between border-b border-outline-variant pb-4 mb-4"> | |
| <h3 className="font-semibold text-on-surface m-0 flex items-center gap-2 text-lg"> | |
| <span className="material-symbols-outlined text-primary">person</span> | |
| Organization Profile | |
| </h3> | |
| <button onClick={handleEditOpen} className="text-primary hover:text-on-primary-container bg-primary-container/20 hover:bg-primary-container px-3 py-1.5 rounded-lg text-xs font-semibold uppercase tracking-wider transition-colors flex items-center gap-1 cursor-pointer border-none"> | |
| <span className="material-symbols-outlined text-[14px]">edit</span> | |
| Edit Profile | |
| </button> | |
| </div> | |
| <div className="flex flex-col items-center text-center mt-2 mb-6"> | |
| <div className="w-20 h-20 rounded-full bg-primary-container/50 border-2 border-primary/20 flex items-center justify-center mb-4 shadow-sm"> | |
| <span className="text-primary text-3xl font-bold uppercase tracking-wider"> | |
| {getInitials(profile)} | |
| </span> | |
| </div> | |
| <h2 className="font-bold text-on-surface m-0 text-xl"> | |
| {profile.first_name || profile.last_name ? `${profile.first_name || ''} ${profile.last_name || ''}`.trim() : (profile.name || profile.email.split('@')[0])} | |
| </h2> | |
| <div className="mt-2 inline-flex items-center px-3 py-1 rounded-full bg-primary-container/30 text-primary font-semibold uppercase tracking-wider text-xs border border-primary/20"> | |
| {(profile.role || 'user').replace(/_/g, ' ')} | |
| </div> | |
| </div> | |
| <div className="flex flex-col w-full mb-6"> | |
| <div className="flex items-center justify-between py-3 border-b border-outline-variant"> | |
| <div className="flex items-center gap-2 text-on-surface-variant"> | |
| <span className="material-symbols-outlined text-[18px]">mail</span> | |
| <span className="font-medium text-sm">Email</span> | |
| </div> | |
| <span className="text-on-surface font-medium text-sm truncate max-w-[150px]" title={profile.email}>{profile.email}</span> | |
| </div> | |
| <div className="flex items-center justify-between py-3 border-b border-outline-variant"> | |
| <div className="flex items-center gap-2 text-on-surface-variant"> | |
| <span className="material-symbols-outlined text-[18px]">calendar_today</span> | |
| <span className="font-medium text-sm">Joined</span> | |
| </div> | |
| <span className="text-on-surface font-medium text-sm"> | |
| {profile.created_at ? new Date(profile.created_at).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }).replace(/ /g, '-') : 'N/A'} | |
| </span> | |
| </div> | |
| </div> | |
| <div className="mt-auto"> | |
| <button | |
| onClick={logout} | |
| className="w-full bg-transparent text-error border border-error/50 hover:bg-error/10 font-medium px-4 py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2 text-sm cursor-pointer" | |
| > | |
| <span className="material-symbols-outlined text-[18px]">logout</span> | |
| Sign Out Securely | |
| </button> | |
| </div> | |
| </div> | |
| {/* Card 2: Security Configuration */} | |
| <div className="bg-surface-container-lowest border border-outline-variant rounded-lg shadow-sm p-6 flex flex-col h-full"> | |
| <h3 className="font-semibold text-on-surface m-0 mb-4 flex items-center gap-2 border-b border-outline-variant pb-4 text-lg"> | |
| <span className="material-symbols-outlined text-primary">security</span> | |
| Security Config | |
| </h3> | |
| <div className="flex flex-col flex-1"> | |
| <div className="mb-5"> | |
| <h4 className="font-semibold text-on-surface m-0 text-sm">Password Management</h4> | |
| <p className="text-on-surface-variant m-0 mt-1 text-xs">Update your account password securely.</p> | |
| </div> | |
| {(profile.role === 'soc_analyst' || profile.role === 'executive_user') ? ( | |
| <div className="bg-error/10 rounded-lg p-4 flex flex-col items-center text-center gap-3 text-error border border-error/20 mt-auto mb-auto"> | |
| <span className="material-symbols-outlined text-[24px]">lock</span> | |
| <p className="text-sm m-0 font-medium"> | |
| Your account type is not permitted to change its own password. Please contact your administrator. | |
| </p> | |
| </div> | |
| ) : ( | |
| <form onSubmit={handlePasswordChange} className="flex flex-col gap-4 flex-1"> | |
| {passwordStatus.error && ( | |
| <div className="bg-error/10 text-error px-md py-sm rounded-lg text-sm border border-error/20 flex items-center gap-2"> | |
| <span className="material-symbols-outlined text-[16px]">error</span> | |
| {passwordStatus.error} | |
| </div> | |
| )} | |
| {passwordStatus.success && ( | |
| <div className="bg-primary-container/20 text-primary px-md py-sm rounded-lg text-sm border border-primary/20 flex items-center gap-2"> | |
| <span className="material-symbols-outlined text-[16px]">check_circle</span> | |
| Password updated successfully! | |
| </div> | |
| )} | |
| <div className="flex flex-col"> | |
| <label className="block text-label-sm font-label-sm text-on-surface-variant mb-xs">Current Password</label> | |
| <div className="relative"> | |
| <input | |
| type={showPassword.current ? "text" : "password"} | |
| required | |
| value={passwordData.currentPassword} | |
| onChange={(e) => setPasswordData({ ...passwordData, currentPassword: e.target.value })} | |
| className="w-full bg-surface-container border border-outline-variant text-on-surface rounded-lg px-md py-sm focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors pr-10" | |
| /> | |
| <button type="button" onClick={() => setShowPassword({ ...showPassword, current: !showPassword.current })} className="absolute inset-y-0 right-0 pr-3 flex items-center text-on-surface-variant hover:text-on-surface bg-transparent border-none cursor-pointer"> | |
| <span className="material-symbols-outlined text-[18px]">{showPassword.current ? 'visibility_off' : 'visibility'}</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="flex flex-col"> | |
| <label className="block text-label-sm font-label-sm text-on-surface-variant mb-xs">New Password</label> | |
| <div className="relative"> | |
| <input | |
| type={showPassword.new ? "text" : "password"} | |
| required | |
| minLength={6} | |
| value={passwordData.newPassword} | |
| onChange={(e) => setPasswordData({ ...passwordData, newPassword: e.target.value })} | |
| className="w-full bg-surface-container border border-outline-variant text-on-surface rounded-lg px-md py-sm focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors pr-10" | |
| /> | |
| <button type="button" onClick={() => setShowPassword({ ...showPassword, new: !showPassword.new })} className="absolute inset-y-0 right-0 pr-3 flex items-center text-on-surface-variant hover:text-on-surface bg-transparent border-none cursor-pointer"> | |
| <span className="material-symbols-outlined text-[18px]">{showPassword.new ? 'visibility_off' : 'visibility'}</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="flex flex-col"> | |
| <label className="block text-label-sm font-label-sm text-on-surface-variant mb-xs">Confirm New Password</label> | |
| <div className="relative"> | |
| <input | |
| type={showPassword.confirm ? "text" : "password"} | |
| required | |
| minLength={6} | |
| value={passwordData.confirmPassword} | |
| onChange={(e) => setPasswordData({ ...passwordData, confirmPassword: e.target.value })} | |
| className="w-full bg-surface-container border border-outline-variant text-on-surface rounded-lg px-md py-sm focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors pr-10" | |
| /> | |
| <button type="button" onClick={() => setShowPassword({ ...showPassword, confirm: !showPassword.confirm })} className="absolute inset-y-0 right-0 pr-3 flex items-center text-on-surface-variant hover:text-on-surface bg-transparent border-none cursor-pointer"> | |
| <span className="material-symbols-outlined text-[18px]">{showPassword.confirm ? 'visibility_off' : 'visibility'}</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="mt-auto pt-2"> | |
| <button | |
| type="submit" | |
| disabled={passwordStatus.loading} | |
| className="w-full bg-primary hover:brightness-110 border-none cursor-pointer text-on-primary font-medium px-4 py-2.5 rounded-lg transition-colors flex items-center justify-center gap-2 disabled:opacity-50 text-sm" | |
| > | |
| {passwordStatus.loading ? ( | |
| <><span className="material-symbols-outlined animate-spin text-[18px]">sync</span> Updating...</> | |
| ) : ( | |
| <><span className="material-symbols-outlined text-[18px]">key</span> Update Password</> | |
| )} | |
| </button> | |
| </div> | |
| </form> | |
| )} | |
| </div> | |
| </div> | |
| {/* Card 3: Subscription Card */} | |
| <div className="bg-surface-container-lowest border border-outline-variant rounded-lg shadow-sm p-6 flex flex-col h-full"> | |
| <h3 className="font-semibold text-on-surface m-0 mb-6 flex items-center gap-2 border-b border-outline-variant pb-4 text-lg"> | |
| <span className="material-symbols-outlined text-primary">workspace_premium</span> | |
| Subscription Status | |
| </h3> | |
| <div className="flex flex-col w-full mb-6 flex-1"> | |
| <div className="flex items-center justify-between py-4 border-b border-outline-variant"> | |
| <div className="flex items-center gap-2 text-on-surface-variant"> | |
| <span className="material-symbols-outlined text-[20px]">inventory_2</span> | |
| <span className="font-medium text-sm">Current Plan</span> | |
| </div> | |
| <span className="text-primary font-bold text-sm uppercase tracking-widest bg-primary-container/30 px-3 py-1 rounded-full border border-primary/20"> | |
| {profile.role === 'super_admin' ? 'Enterprise' : profile.subscription_tier || 'Free'} | |
| </span> | |
| </div> | |
| <div className="flex items-center justify-between py-4 border-b border-outline-variant"> | |
| <div className="flex items-center gap-2 text-on-surface-variant"> | |
| <span className="material-symbols-outlined text-[20px]">speed</span> | |
| <span className="font-medium text-sm">Status</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <div className={`w-2 h-2 rounded-full ${profile.role === 'super_admin' || profile.subscription_status === 'active' ? 'bg-green-500' : 'bg-yellow-500'}`}></div> | |
| <span className={`font-bold uppercase tracking-wide text-sm ${profile.role === 'super_admin' || profile.subscription_status === 'active' ? 'text-green-600' : 'text-yellow-600'}`}> | |
| {profile.role === 'super_admin' ? 'Active' : profile.subscription_status || 'Inactive'} | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="mt-auto"> | |
| <div className="bg-surface-variant/30 border border-outline-variant/50 rounded-lg p-4 flex items-start gap-3"> | |
| <span className="material-symbols-outlined text-primary text-[18px] shrink-0 mt-0.5">info</span> | |
| <p className="text-on-surface-variant m-0 text-xs leading-relaxed"> | |
| Need higher API limits, custom proxy integrations, or VIP enterprise support? Contact your organization admin or LarShield sales to upgrade your tier. | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Second Row for Report Branding */} | |
| {(profile.role === 'super_admin' || profile.role === 'org_admin') && ( | |
| <div className="w-full bg-white border border-[#e5e7eb] rounded-xl shadow-xs p-6 md:p-8 mt-6"> | |
| <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4"> | |
| <div> | |
| <div className="flex items-center gap-2"> | |
| <span className="material-symbols-outlined text-[#2563eb] text-[22px]">palette</span> | |
| <h3 className="font-bold text-[#111827] text-[18px] m-0"> | |
| Report Branding | |
| </h3> | |
| </div> | |
| <p className="text-[#4b5563] text-sm mt-1 m-0"> | |
| Customize generated PDF security reports with your organization's logo. | |
| </p> | |
| </div> | |
| {reportLogoUrl && ( | |
| <button | |
| type="button" | |
| onClick={handleRemoveLogo} | |
| className="shrink-0 border border-[#fecaca] hover:bg-[#fef2f2] hover:border-[#fca5a5] text-[#dc2626] font-medium text-xs px-3.5 py-2 rounded-lg flex items-center gap-1.5 transition-colors cursor-pointer border-solid self-start sm:self-auto" | |
| > | |
| <span className="material-symbols-outlined text-[16px]">delete</span> | |
| <span>Remove Logo</span> | |
| </button> | |
| )} | |
| </div> | |
| <div className="border-t border-[#e5e7eb] pt-6"> | |
| <div | |
| onDragOver={handleDragOver} | |
| onDragEnter={handleDragOver} | |
| onDragLeave={handleDragLeave} | |
| onDrop={handleDrop} | |
| onClick={() => fileInputRef.current?.click()} | |
| className={`relative border-2 border-dashed rounded-xl py-10 px-6 flex flex-col items-center justify-center text-center transition-all duration-200 cursor-pointer ${ | |
| isDragging | |
| ? 'border-[#2563eb] bg-[#eff6ff] scale-[1.005]' | |
| : 'border-[#d1d5db] bg-[#f9fafb] hover:border-[#9ca3af] hover:bg-[#f3f4f6]' | |
| }`} | |
| > | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept="image/*" | |
| onChange={(e) => { | |
| if (e.target.files && e.target.files[0]) { | |
| handleUploadLogoFile(e.target.files[0]); | |
| } | |
| }} | |
| className="hidden" | |
| /> | |
| {reportLogoUrl ? ( | |
| <> | |
| <div className="mb-6 p-4 bg-white rounded-xl border border-[#e5e7eb] shadow-xs flex items-center justify-center max-w-sm w-full pointer-events-auto"> | |
| <img | |
| src={reportLogoUrl} | |
| alt="Organization Logo" | |
| className="max-h-24 max-w-full object-contain" | |
| onError={(e) => { | |
| if (reportLogoUrl && !e.target.dataset.retried) { | |
| e.target.dataset.retried = '1'; | |
| if (reportLogoUrl.startsWith('/uploads/')) { | |
| e.target.src = `/api/auth${reportLogoUrl}`; | |
| } else if (reportLogoUrl.startsWith('/api/auth/uploads/')) { | |
| e.target.src = reportLogoUrl.replace('/api/auth', ''); | |
| } else if (!reportLogoUrl.startsWith('http')) { | |
| e.target.src = `/uploads/logos/${reportLogoUrl.split('/').pop()}`; | |
| } | |
| } | |
| }} | |
| /> | |
| </div> | |
| <button | |
| type="button" | |
| disabled={uploadingLogo} | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| fileInputRef.current?.click(); | |
| }} | |
| className="relative z-20 bg-[#2563eb] hover:bg-[#1d4ed8] text-white font-semibold px-6 py-2.5 rounded-lg flex items-center gap-2 text-sm shadow-xs transition-colors cursor-pointer mb-4 disabled:opacity-50" | |
| > | |
| <span className={`material-symbols-outlined text-[18px] ${uploadingLogo ? 'animate-spin' : ''}`}> | |
| {uploadingLogo ? 'sync' : 'upload_file'} | |
| </span> | |
| <span>{uploadingLogo ? 'Uploading...' : 'Upload Here (Change Logo)'}</span> | |
| </button> | |
| <p className="text-[#6b7280] text-sm m-0 font-normal pointer-events-none"> | |
| Or drag and drop a new logo file above (PNG, JPG, WebP, SVG up to 5MB) | |
| </p> | |
| </> | |
| ) : ( | |
| <> | |
| <div className="w-12 h-12 rounded-full bg-[#eff6ff] flex items-center justify-center mb-3"> | |
| <span className="material-symbols-outlined text-[#2563eb] text-[28px]">cloud_upload</span> | |
| </div> | |
| <h4 className="font-bold text-[#111827] text-base mb-1 m-0"> | |
| Upload Organization Logo | |
| </h4> | |
| <p className="text-[#6b7280] text-xs mb-5 m-0 font-normal"> | |
| Upload your custom logo to brand all PDF security reports | |
| </p> | |
| <button | |
| type="button" | |
| disabled={uploadingLogo} | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| fileInputRef.current?.click(); | |
| }} | |
| className="relative z-20 bg-[#2563eb] hover:bg-[#1d4ed8] text-white font-semibold px-6 py-2.5 rounded-lg flex items-center gap-2 text-sm shadow-xs transition-colors cursor-pointer mb-4 disabled:opacity-50" | |
| > | |
| <span className={`material-symbols-outlined text-[18px] ${uploadingLogo ? 'animate-spin' : ''}`}> | |
| {uploadingLogo ? 'sync' : 'upload_file'} | |
| </span> | |
| <span>{uploadingLogo ? 'Uploading...' : 'Upload Here'}</span> | |
| </button> | |
| <p className="text-[#6b7280] text-xs m-0 font-normal pointer-events-none"> | |
| Supported formats: PNG, JPG, WebP, SVG (Max 5MB) | |
| </p> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Edit Profile Modal */} | |
| {showEditModal && ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-fade-in"> | |
| <div className="bg-surface-container-lowest border border-outline-variant rounded-lg overflow-hidden shadow-xl max-w-md w-full"> | |
| <div className="p-6 border-b border-outline-variant bg-surface-container/50 flex justify-between items-center"> | |
| <h3 className="text-xl font-bold flex items-center text-on-surface m-0"> | |
| <span className="material-symbols-outlined text-primary mr-2">edit</span> | |
| Edit Profile | |
| </h3> | |
| <button | |
| onClick={() => setShowEditModal(false)} | |
| className="text-on-surface-variant hover:text-on-surface rounded-full p-1 transition-colors cursor-pointer border-0 bg-transparent flex items-center justify-center" | |
| > | |
| <span className="material-symbols-outlined text-[20px]">close</span> | |
| </button> | |
| </div> | |
| <form onSubmit={handleEditSubmit} className="p-6 flex flex-col gap-4"> | |
| {editStatus.error && ( | |
| <div className="bg-error/10 text-error px-md py-sm rounded-lg text-sm border border-error/20 flex items-center gap-2"> | |
| <span className="material-symbols-outlined text-[16px]">error</span> | |
| {editStatus.error} | |
| </div> | |
| )} | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> | |
| <div className="flex flex-col"> | |
| <label className="block text-label-sm font-label-sm text-on-surface-variant mb-xs">First Name</label> | |
| <input | |
| type="text" | |
| value={editData.first_name} | |
| onChange={(e) => setEditData({ ...editData, first_name: e.target.value })} | |
| className="w-full bg-surface-container border border-outline-variant text-on-surface rounded-lg px-md py-sm focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors" | |
| placeholder="Enter first name" | |
| /> | |
| </div> | |
| <div className="flex flex-col"> | |
| <label className="block text-label-sm font-label-sm text-on-surface-variant mb-xs">Last Name</label> | |
| <input | |
| type="text" | |
| value={editData.last_name} | |
| onChange={(e) => setEditData({ ...editData, last_name: e.target.value })} | |
| className="w-full bg-surface-container border border-outline-variant text-on-surface rounded-lg px-md py-sm focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors" | |
| placeholder="Enter last name" | |
| /> | |
| </div> | |
| </div> | |
| <div className="flex flex-col"> | |
| <label className="block text-label-sm font-label-sm text-on-surface-variant mb-xs">Email Address</label> | |
| <input | |
| type="email" | |
| value={editData.email} | |
| onChange={(e) => setEditData({ ...editData, email: e.target.value })} | |
| className="w-full bg-surface-container border border-outline-variant text-on-surface rounded-lg px-md py-sm focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors" | |
| placeholder="Enter email address" | |
| required | |
| /> | |
| </div> | |
| <div className="flex flex-col"> | |
| <label className="block text-label-sm font-label-sm text-on-surface-variant mb-xs">Contact No</label> | |
| <input | |
| type="tel" | |
| value={editData.contact_no} | |
| onChange={(e) => setEditData({ ...editData, contact_no: e.target.value })} | |
| className="w-full bg-surface-container border border-outline-variant text-on-surface rounded-lg px-md py-sm focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors" | |
| placeholder="+1 (555) 000-0000" | |
| /> | |
| </div> | |
| <div className="flex flex-col"> | |
| <div className="flex items-center justify-between mb-xs"> | |
| <label className="block text-label-sm font-label-sm text-on-surface-variant">Organization Name</label> | |
| {profile.role !== 'org_admin' && profile.role !== 'super_admin' && ( | |
| <span className="text-[10px] bg-surface-variant text-on-surface-variant px-2 py-0.5 rounded uppercase tracking-wider font-bold">Admin Only</span> | |
| )} | |
| </div> | |
| <input | |
| type="text" | |
| value={editData.org_name} | |
| onChange={(e) => setEditData({ ...editData, org_name: e.target.value })} | |
| disabled={profile.role !== 'org_admin' && profile.role !== 'super_admin'} | |
| className={`w-full rounded-lg px-md py-sm outline-none transition-colors ${profile.role === 'org_admin' || profile.role === 'super_admin' ? 'bg-surface-container border border-outline-variant text-on-surface focus:border-primary focus:ring-1 focus:ring-primary' : 'bg-surface-variant/50 border border-outline-variant text-on-surface-variant cursor-not-allowed'}`} | |
| /> | |
| </div> | |
| <div className="mt-2 flex gap-sm justify-end"> | |
| <button | |
| type="button" | |
| onClick={() => setShowEditModal(false)} | |
| className="px-xl py-sm bg-transparent border border-outline-variant rounded-lg font-label-md text-on-surface hover:bg-surface-variant transition-colors cursor-pointer" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| type="submit" | |
| disabled={editStatus.loading} | |
| className="px-xl py-sm bg-primary text-on-primary rounded-lg font-label-md hover:brightness-110 transition-all border-none cursor-pointer flex items-center justify-center gap-2 disabled:opacity-50" | |
| > | |
| {editStatus.loading ? ( | |
| <><span className="material-symbols-outlined animate-spin text-[16px]">sync</span> Saving...</> | |
| ) : ( | |
| "Save Changes" | |
| )} | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default Profile; | |