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 = () => { } /> + + + + } + /> { + 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 Dashboard = () => { const [roomId, setRoomId] = useState(""); const [isModalOpen, setIsModalOpen] = useState(false); @@ -31,9 +40,31 @@ const Dashboard = () => { const [copiedBoardId, setCopiedBoardId] = useState(null); const navigate = useNavigate(); const location = useLocation(); - const user = JSON.parse(localStorage.getItem("user")); + const [user, setUser] = useState(() => { + return JSON.parse(localStorage.getItem("user") || "{}"); + }); const isTeacher = user?.role === "teacher"; + // Sync latest user profile on mount if not already cached + useEffect(() => { + if (user && (user.id || user._id) && user.username) { + return; + } + + const fetchProfile = async () => { + try { + const res = await api.get("/api/auth/profile"); + if (res.data && res.data.user) { + localStorage.setItem("user", JSON.stringify(res.data.user)); + setUser(res.data.user); + } + } catch (err) { + console.error("Failed to sync user profile:", err); + } + }; + fetchProfile(); + }, []); + // Redirect admin to admin panel if they somehow reach this page useEffect(() => { if (user?.role === "admin") { @@ -231,6 +262,21 @@ const Dashboard = () => {

+

{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 ( +

+
+
+ ); + } + + return ( +
+
+ {/* Back Link */} +
+ + Back to Dashboard + +
+ + {/* Header */} +
+
+ +
+

Edit Profile

+
+ + {/* Messages */} + + {error && ( + +
+ {error} +
+ )} + {successMessage && ( + + + {successMessage} + + )} +
+ + {/* Main Content Layout */} +
+ {/* LEFT PANEL - Profile Picture Card */} +
+
+ + {/* Profile Picture */} +
+ {previewUrl ? ( + {username} + ) : ( + + {getInitials(fullName, username)} + + )} +
+ + {/* Hidden File Input */} + + + {/* Action Buttons */} +
+ + {previewUrl && ( + + )} +
+ + {/* Formats and Size Help Text */} +
+

Accepted formats: JPG, JPEG, PNG, WebP

+

Maximum size: 5 MB

+
+
+ + {/* RIGHT PANEL - Profile Details Fields */} +
+
+ {/* Full Name */} +
+ +
+ + setFullName(e.target.value)} + className="w-full input-glass pl-12 pr-4 py-3.5 rounded-xl focus:outline-none text-sm" + placeholder="Enter your full name" + required + /> +
+
+ + {/* Username */} +
+ +
+ + setUsername(e.target.value)} + className="w-full input-glass pl-12 pr-4 py-3.5 rounded-xl focus:outline-none text-sm" + placeholder="johndoe123" + required + /> +
+
+
+ +
+ {/* Email */} +
+ +
+ + setEmail(e.target.value)} + className="w-full input-glass pl-12 pr-4 py-3.5 rounded-xl focus:outline-none text-sm" + placeholder="john@example.com" + required + /> +
+
+ + {/* Phone Number */} +
+ +
+ + setPhoneNumber(e.target.value)} + className="w-full input-glass pl-12 pr-4 py-3.5 rounded-xl focus:outline-none text-sm" + placeholder="+1 (555) 000-0000" + /> +
+
+
+ +
+ {/* Educational Institution */} +
+ +
+ + setEducationalInstitution(e.target.value)} + className="w-full input-glass pl-12 pr-4 py-3.5 rounded-xl focus:outline-none text-sm" + placeholder="Enter your school or college" + /> +
+
+ + {/* Course / Department */} +
+ +
+ + setCourseOrDepartment(e.target.value)} + className="w-full input-glass pl-12 pr-4 py-3.5 rounded-xl focus:outline-none text-sm" + placeholder="Enter your course or department" + /> +
+
+
+ + {/* Bio */} +
+ +
+ +