diff --git a/.gitignore b/.gitignore
index ce5c0972..1c5d786d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,3 +48,7 @@ coverage/
*.tmp
*.temp
.cache/
+
+# Uploaded user files
+server/public/uploads/*
+!server/public/uploads/.gitkeep
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 29f7760a..dad249ec 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -26,6 +26,7 @@ import { ThemeProvider } from "./context/ThemeContext";
import TermsOfService from "./pages/TermsOfService";
import PrivacyPolicy from "./pages/PrivacyPolicy";
import NotFound from "./pages/NotFound";
+import Profile from "./pages/Profile";
const PrivateRoute = ({ children }) => {
const token = localStorage.getItem("token");
@@ -168,6 +169,14 @@ const AppLayout = () => {
}
/>
+
{user?.username}
diff --git a/client/src/pages/Login.jsx b/client/src/pages/Login.jsx index 932a8356..e953aa0d 100644 --- a/client/src/pages/Login.jsx +++ b/client/src/pages/Login.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Link, useNavigate, useLocation } from 'react-router-dom'; import axios from 'axios'; import { motion } from 'framer-motion'; @@ -41,7 +41,11 @@ const Login = () => { const [loading, setLoading] = useState(false); const [tokenClient, setTokenClient] = useState(null); - const handleGoogleLogin = async (accessToken) => { + // Use a ref so the Google OAuth callback always gets the latest handler + // without recreating the tokenClient on every render (fixes stale closure bug) + const handleGoogleLoginRef = useRef(null); + + const handleGoogleLogin = useCallback(async (accessToken) => { setError(''); setLoading(true); try { @@ -57,29 +61,49 @@ const Login = () => { navigate(from); } } catch (err) { - setError(err.response?.data?.message || 'Google login failed'); + setError(err.response?.data?.message || 'Google login failed. Please try again.'); setLoading(false); } - }; + }, [navigate, from]); + // Keep ref always pointing to the latest handler useEffect(() => { + handleGoogleLoginRef.current = handleGoogleLogin; + }, [handleGoogleLogin]); + + useEffect(() => { + const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID; + + // Don't initialize if the client ID is the placeholder or missing + if (!clientId || clientId === 'your_google_client_id_here') { + console.warn('VITE_GOOGLE_CLIENT_ID is not configured. Google Sign-In is disabled.'); + return; + } + let timer; const initializeGoogleClient = () => { - if (window.google) { + if (window.google && window.google.accounts && window.google.accounts.oauth2) { try { const client = window.google.accounts.oauth2.initTokenClient({ - client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID, + client_id: clientId, scope: 'openid email profile', + // Use ref so callback always calls the latest handler, + // avoiding the stale closure problem callback: async (tokenResponse) => { if (tokenResponse && tokenResponse.access_token) { - await handleGoogleLogin(tokenResponse.access_token); + await handleGoogleLoginRef.current(tokenResponse.access_token); + } else if (tokenResponse && tokenResponse.error) { + // User dismissed the popup or an error occurred + if (tokenResponse.error !== 'access_denied') { + setError(`Google sign-in error: ${tokenResponse.error}`); + } } }, }); setTokenClient(client); if (timer) clearInterval(timer); } catch (err) { - console.error("Error initializing Google OAuth client:", err); + console.error('Error initializing Google OAuth client:', err); } } }; @@ -93,10 +117,15 @@ const Login = () => { }, []); const handleGoogleClick = () => { + const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID; + if (!clientId || clientId === 'your_google_client_id_here') { + setError('Google Sign-In is not configured. Please set VITE_GOOGLE_CLIENT_ID in the .env file.'); + return; + } if (tokenClient) { tokenClient.requestAccessToken(); } else { - setError("Google sign-in is not ready yet. Please try again."); + setError('Google sign-in is not ready yet. Please wait a moment and try again.'); } }; diff --git a/client/src/pages/Profile.jsx b/client/src/pages/Profile.jsx new file mode 100644 index 00000000..b5516e7e --- /dev/null +++ b/client/src/pages/Profile.jsx @@ -0,0 +1,490 @@ +import React, { useState, useEffect, useRef } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { motion, AnimatePresence } from "framer-motion"; +import { + FaArrowLeft, + FaUser, + FaEnvelope, + FaPhone, + FaGraduationCap, + FaBuilding, + FaFileAlt, + FaCamera, + FaTrash, + FaCheck, +} from "react-icons/fa"; +import { BsLightningChargeFill } from "react-icons/bs"; +import api from "../lib/api"; + +const getInitials = (fullName, username) => { + const name = fullName || username || "?"; + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + return parts[0].slice(0, 2).toUpperCase(); +}; + +const Profile = () => { + const navigate = useNavigate(); + const fileInputRef = useRef(null); + + // Form fields state + const [fullName, setFullName] = useState(""); + const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); + const [educationalInstitution, setEducationalInstitution] = useState(""); + const [courseOrDepartment, setCourseOrDepartment] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [bio, setBio] = useState(""); + + // Picture state + const [profilePicture, setProfilePicture] = useState(""); + const [previewUrl, setPreviewUrl] = useState(""); + const [selectedFile, setSelectedFile] = useState(null); + const [isPhotoRemoved, setIsPhotoRemoved] = useState(false); + + // Alert & status state + const [loading, setLoading] = useState(false); + const [fetching, setFetching] = useState(true); + const [error, setError] = useState(""); + const [successMessage, setSuccessMessage] = useState(""); + + // Load profile on mount + useEffect(() => { + const fetchUserProfile = async () => { + try { + const res = await api.get("/api/auth/profile"); + if (res.data && res.data.user) { + const u = res.data.user; + setFullName(u.fullName || ""); + setUsername(u.username || ""); + setEmail(u.email || ""); + setEducationalInstitution(u.educationalInstitution || ""); + setCourseOrDepartment(u.courseOrDepartment || ""); + setPhoneNumber(u.phoneNumber || ""); + setBio(u.bio || ""); + setProfilePicture(u.profilePicture || ""); + setPreviewUrl(u.profilePicture || ""); + + // Update localStorage just in case it is out of sync + localStorage.setItem("user", JSON.stringify(u)); + } + } catch (err) { + console.error("Error fetching profile details:", err); + setError("Failed to load profile details. Please try again."); + } finally { + setFetching(false); + } + }; + fetchUserProfile(); + }, []); + + // Error & Success auto-dismiss + useEffect(() => { + if (error) { + const timer = setTimeout(() => setError(""), 5000); + return () => clearTimeout(timer); + } + }, [error]); + + useEffect(() => { + if (successMessage) { + const timer = setTimeout(() => setSuccessMessage(""), 5000); + return () => clearTimeout(timer); + } + }, [successMessage]); + + // Handle profile picture changes + const handleFileChange = (e) => { + const file = e.target.files[0]; + if (!file) return; + + // Validation: Format (JPG, JPEG, PNG, WebP) + const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/jpg"]; + if (!allowedTypes.includes(file.type)) { + setError("Invalid file type. Only JPG, JPEG, PNG, and WEBP formats are accepted."); + return; + } + + // Validation: Size (Max 5MB) + const maxSizeBytes = 5 * 1024 * 1024; + if (file.size > maxSizeBytes) { + setError("File too large. Maximum allowed size is 5MB."); + return; + } + + setSelectedFile(file); + setIsPhotoRemoved(false); + + // Immediate frontend preview + const objectUrl = URL.createObjectURL(file); + setPreviewUrl(objectUrl); + }; + + const triggerFileSelect = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + const handleRemovePhoto = () => { + setSelectedFile(null); + setPreviewUrl(""); + setIsPhotoRemoved(true); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + // Save changes + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + setSuccessMessage(""); + + // Validation + if (!fullName.trim()) { + setError("Full Name is required."); + return; + } + if (!email.trim()) { + setError("Email is required."); + return; + } + if (!username.trim()) { + setError("Username is required."); + return; + } + + const emailRegex = /.+@.+\..+/; + if (!emailRegex.test(email.trim())) { + setError("Please enter a valid email address."); + return; + } + + setLoading(true); + + try { + let finalProfilePictureUrl = profilePicture; + + // Step 1: Upload photo if selected + if (selectedFile) { + const formData = new FormData(); + formData.append("image", selectedFile); + + const uploadRes = await api.post("/api/auth/profile/upload", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + + if (uploadRes.data && uploadRes.data.imageUrl) { + finalProfilePictureUrl = uploadRes.data.imageUrl; + } else { + throw new Error("Failed to retrieve uploaded image path."); + } + } else if (isPhotoRemoved) { + finalProfilePictureUrl = ""; + } + + // Step 2: Update Profile details + const payload = { + fullName: fullName.trim(), + username: username.trim(), + email: email.trim(), + educationalInstitution: educationalInstitution.trim(), + courseOrDepartment: courseOrDepartment.trim(), + phoneNumber: phoneNumber.trim(), + bio: bio.trim(), + profilePicture: finalProfilePictureUrl, + }; + + const profileRes = await api.put("/api/auth/profile", payload); + + if (profileRes.data && profileRes.data.user) { + const updatedUser = profileRes.data.user; + + // Update local storage and component state + localStorage.setItem("user", JSON.stringify(updatedUser)); + setProfilePicture(updatedUser.profilePicture || ""); + setPreviewUrl(updatedUser.profilePicture || ""); + setSelectedFile(null); + setIsPhotoRemoved(false); + setSuccessMessage("Changes saved successfully!"); + } + } catch (err) { + console.error("Save profile error:", err); + setError(err.response?.data?.message || "Failed to update profile details. Please try again."); + } finally { + setLoading(false); + } + }; + + if (fetching) { + return ( +