From 2ba9a53e5dd85872990db59b6ca02d8632f4e740 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 18 Nov 2025 12:36:26 -0500 Subject: [PATCH 01/46] feat: implement user icon, login and signup UI; refs #111 --- src/components/NavBar/NavItems.tsx | 427 ++++++++++++++++------------- src/components/User/UserButton.tsx | 36 ++- src/components/User/UserLogin.tsx | 217 +++++++++++++++ src/components/User/UserSignup.tsx | 320 +++++++++++++++++++++ 4 files changed, 803 insertions(+), 197 deletions(-) diff --git a/src/components/NavBar/NavItems.tsx b/src/components/NavBar/NavItems.tsx index c27dbb8..1756975 100644 --- a/src/components/NavBar/NavItems.tsx +++ b/src/components/NavBar/NavItems.tsx @@ -1,20 +1,47 @@ import { Toolbar, Grid, Button, Typography, Box, Tooltip } from "@mui/material"; import UserButton from "components/User/UserButton"; +import UserLogin from "components/User/UserLogin"; +import UserSignup from "components/User/UserSignup"; import { Colors } from "design/theme"; -import React from "react"; +import React, { useState } from "react"; import { useNavigate, Link } from "react-router-dom"; import RoutesEnum from "types/routes.enum"; const NavItems: React.FC = () => { const navigate = useNavigate(); + // Modal state + const [loginOpen, setLoginOpen] = useState(false); + const [signupOpen, setSignupOpen] = useState(false); + + // TODO: Replace with actual authentication state from your auth context/redux + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [userName, setUserName] = useState(""); + // for user test - const isLoggedIn = false; - const userName = "John Doe"; + // const isLoggedIn = false; + // const userName = "John Doe"; + + const handleLoginSuccess = (name: string) => { + setUserName(name); + setIsLoggedIn(true); + // TODO: Store auth token in localStorage or context + // localStorage.setItem('authToken', token); + }; + + const handleSignupSuccess = (name: string) => { + setUserName(name); + setIsLoggedIn(true); + // TODO: Store auth token in localStorage or context + // localStorage.setItem('authToken', token); + }; + const handleLogout = () => { - // TODO: Implement your logout logic here - console.log("Logging out..."); - // Example: dispatch(logout()) or authService.logout() + setIsLoggedIn(false); + setUserName(""); + // TODO: Clear auth token + // localStorage.removeItem('authToken'); + navigate("/"); }; return ( @@ -27,85 +54,86 @@ const NavItems: React.FC = () => { // }} // > // - - + navigate("/")} - sx={{ - height: { xs: 50, sm: 65, md: 80 }, - width: "auto", - display: { xs: "block", sm: "block", md: "none" }, - }} - > - - {/* */} - - {/* // */} + + NeuroJSON.io + + + Free Data Worth Sharing + + + {/* */} + + {/* // */} - {/* Navigation links*/} - {/* + {/* Navigation links*/} + {/* { rowGap: { xs: 1, sm: 2 }, }} > */} - - {[ - { text: "About", url: RoutesEnum.ABOUT }, - { text: "Wiki", url: "https://neurojson.org/Wiki" }, - { text: "Search", url: RoutesEnum.SEARCH }, - { text: "Databases", url: RoutesEnum.DATABASES }, - { - text: "V1", - url: "https://neurojson.io/v1", - tooltip: "Visit the previous version of website", - }, - ].map(({ text, url, tooltip }) => ( - - {tooltip ? ( - + {[ + { text: "About", url: RoutesEnum.ABOUT }, + { text: "Wiki", url: "https://neurojson.org/Wiki" }, + { text: "Search", url: RoutesEnum.SEARCH }, + { text: "Databases", url: RoutesEnum.DATABASES }, + { + text: "V1", + url: "https://neurojson.io/v1", + tooltip: "Visit the previous version of website", + }, + ].map(({ text, url, tooltip }) => ( + + {tooltip ? ( + + }} + > + + + {text} + + + + ) : url?.startsWith("https") ? ( { lineHeight={"1.5rem"} letterSpacing={"0.05rem"} sx={{ + fontSize: { + xs: "0.8rem", // font size on mobile + sm: "1rem", + }, color: Colors.white, transition: "color 0.3s ease, transform 0.3s ease", textTransform: "uppercase", @@ -186,86 +244,77 @@ const NavItems: React.FC = () => { {text} - - ) : url?.startsWith("https") ? ( - - - {text} - - - ) : ( - - - {text} - - - )} - - ))} - + ) : ( + + + {text} + + + )} + + ))} + - {/* User Button */} - + setLoginOpen(true)} + onOpenSignup={() => setSignupOpen(true)} + /> + + {/* */} + {/* */} + {/* */} + {/* */} + + setLoginOpen(false)} + onSwitchToSignup={() => { + setLoginOpen(false); + setSignupOpen(true); }} - > - {/* */} - - {/* */} - {/* */} - {/* */} - {/* */} - + onLoginSuccess={handleLoginSuccess} + /> + setSignupOpen(false)} + onSwitchToLogin={() => { + setSignupOpen(false); + setLoginOpen(true); + }} + onSignupSuccess={handleSignupSuccess} + /> + ); }; diff --git a/src/components/User/UserButton.tsx b/src/components/User/UserButton.tsx index 3438533..c6bf3e0 100644 --- a/src/components/User/UserButton.tsx +++ b/src/components/User/UserButton.tsx @@ -23,12 +23,16 @@ interface UserButtonProps { isLoggedIn: boolean; userName?: string; onLogout?: () => void; + onOpenLogin: () => void; + onOpenSignup: () => void; } const UserButton: React.FC = ({ isLoggedIn, userName, onLogout, + onOpenLogin, + onOpenSignup, }) => { const [anchorEl, setAnchorEl] = useState(null); const navigate = useNavigate(); @@ -42,6 +46,28 @@ const UserButton: React.FC = ({ setAnchorEl(null); }; + const handleMenuItemClick = (path: string) => { + handleClose(); + navigate(path); + }; + + const handleLogout = () => { + handleClose(); + if (onLogout) { + onLogout(); + } + }; + + const handleLogin = () => { + handleClose(); + onOpenLogin(); + }; + + const handleSignup = () => { + handleClose(); + onOpenSignup(); + }; + return ( <> = ({ > {!isLoggedIn ? ( <> - handleMenuItemClick(RoutesEnum.LOGIN)} - > + {/* */} Sign In - handleMenuItemClick(RoutesEnum.SIGNUP)} - > + {/* */} diff --git a/src/components/User/UserLogin.tsx b/src/components/User/UserLogin.tsx index e69de29..eda0a23 100644 --- a/src/components/User/UserLogin.tsx +++ b/src/components/User/UserLogin.tsx @@ -0,0 +1,217 @@ +import { Close, Visibility, VisibilityOff } from "@mui/icons-material"; +import { + Dialog, + DialogTitle, + DialogContent, + TextField, + Button, + Box, + Typography, + IconButton, + InputAdornment, + Alert, +} from "@mui/material"; +import { Colors } from "design/theme"; +import React, { useState } from "react"; + +interface UserLoginProps { + open: boolean; + onClose: () => void; + onSwitchToSignup: () => void; + onLoginSuccess: (userName: string) => void; +} + +const UserLogin: React.FC = ({ + open, + onClose, + onSwitchToSignup, + onLoginSuccess, +}) => { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + // TODO: Replace with your actual API call + // const response = await authService.login(email, password); + + // Mock API call (remove this in production) + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Mock successful login + if (email && password) { + onLoginSuccess("John Doe"); // Replace with actual user data from API + handleClose(); + } else { + setError("Please enter both email and password"); + } + } catch (err) { + setError("Invalid email or password. Please try again."); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setEmail(""); + setPassword(""); + setError(""); + setShowPassword(false); + onClose(); + }; + + const handleSwitchToSignup = () => { + handleClose(); + onSwitchToSignup(); + }; + return ( + + + + Sign In + + + + + + + + {error && ( + + {error} + + )} + setEmail(e.target.value)} + required + sx={{ + mb: 2, + "& .MuiOutlinedInput-root": { + color: Colors.darkPurple, + "& fieldset": { borderColor: Colors.primary.light }, + "&:hover fieldset": { borderColor: Colors.purple }, + "&.Mui-focused fieldset": { borderColor: Colors.purple }, + }, + "& .MuiInputLabel-root": { + color: Colors.primary.light, + "&.Mui-focused": { color: Colors.purple }, + }, + }} + /> + setPassword(e.target.value)} + required + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} + edge="end" + sx={{ color: Colors.primary.light }} + > + {showPassword ? : } + + + ), + }} + sx={{ + mb: 3, + "& .MuiOutlinedInput-root": { + color: Colors.darkPurple, + "& fieldset": { borderColor: Colors.primary.light }, + "&:hover fieldset": { borderColor: Colors.purple }, + "&.Mui-focused fieldset": { borderColor: Colors.purple }, + }, + "& .MuiInputLabel-root": { + color: Colors.primary.light, + "&.Mui-focused": { color: Colors.purple }, + }, + }} + /> + + + + Don't have an account?{" "} + + Create Account + + + + + + + ); +}; + +export default UserLogin; diff --git a/src/components/User/UserSignup.tsx b/src/components/User/UserSignup.tsx index e69de29..0d8970a 100644 --- a/src/components/User/UserSignup.tsx +++ b/src/components/User/UserSignup.tsx @@ -0,0 +1,320 @@ +import { Close, Visibility, VisibilityOff } from "@mui/icons-material"; +import { + Dialog, + DialogTitle, + DialogContent, + TextField, + Button, + Box, + Typography, + IconButton, + InputAdornment, + Alert, +} from "@mui/material"; +import { Colors } from "design/theme"; +import React, { useState } from "react"; + +interface UserSignupProps { + open: boolean; + onClose: () => void; + onSwitchToLogin: () => void; + onSignupSuccess: (userName: string) => void; +} + +const UserSignup: React.FC = ({ + open, + onClose, + onSwitchToLogin, + onSignupSuccess, +}) => { + const [formData, setFormData] = useState({ + name: "", + email: "", + password: "", + confirmPassword: "", + }); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleChange = + (field: string) => (e: React.ChangeEvent) => { + setFormData({ ...formData, [field]: e.target.value }); + }; + + const validateForm = () => { + if ( + !formData.name || + !formData.email || + !formData.password || + !formData.confirmPassword + ) { + setError("Please fill in all fields"); + return false; + } + + if (formData.password.length < 8) { + setError("Password must be at least 8 characters long"); + return false; + } + + if (formData.password !== formData.confirmPassword) { + setError("Passwords do not match"); + return false; + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(formData.email)) { + setError("Please enter a valid email address"); + return false; + } + + return true; + }; + + const handleClose = () => { + setFormData({ + name: "", + email: "", + password: "", + confirmPassword: "", + }); + setError(""); + setShowPassword(false); + setShowConfirmPassword(false); + onClose(); + }; + + const handleSwitchToLogin = () => { + handleClose(); + onSwitchToLogin(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (!validateForm()) { + return; + } + + setLoading(true); + + try { + // TODO: Replace with your actual API call + // const response = await authService.signup(formData); + + // Mock API call (remove this in production) + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Mock successful signup + onSignupSuccess(formData.name); + handleClose(); + } catch (error) { + setError("An error occurred during signup. Please try again."); + } finally { + setLoading(false); + } + }; + + return ( + + + + Create Account + + + + + + + + {error && ( + + {error} + + )} + + + + + + setShowPassword(!showPassword)} + edge="end" + sx={{ color: Colors.primary.light }} + > + {showPassword ? : } + + + ), + }} + sx={{ + mb: 2, + "& .MuiOutlinedInput-root": { + color: Colors.darkPurple, + "& fieldset": { borderColor: Colors.primary.light }, + "&:hover fieldset": { borderColor: Colors.purple }, + "&.Mui-focused fieldset": { borderColor: Colors.purple }, + }, + "& .MuiInputLabel-root": { + color: Colors.primary.light, + "&.Mui-focused": { color: Colors.purple }, + }, + }} + /> + + setShowConfirmPassword(!showConfirmPassword)} + edge="end" + sx={{ color: Colors.primary.light }} + > + {showConfirmPassword ? : } + + + ), + }} + sx={{ + mb: 2, + "& .MuiOutlinedInput-root": { + color: Colors.darkPurple, + "& fieldset": { borderColor: Colors.primary.light }, + "&:hover fieldset": { borderColor: Colors.purple }, + "&.Mui-focused fieldset": { borderColor: Colors.purple }, + }, + "& .MuiInputLabel-root": { + color: Colors.primary.light, + "&.Mui-focused": { color: Colors.purple }, + }, + }} + /> + + + + Already have an account?{" "} + + Sign In + + + + + + + ); +}; + +export default UserSignup; From bfa0ee95c4ed4b24d62c53e9067597ebca6cf7c4 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 18 Nov 2025 15:07:36 -0500 Subject: [PATCH 02/46] feat: add login and logout functionalities; refs #111 --- src/components/NavBar/NavItems.tsx | 41 ++++++++++++++++++++++-------- src/components/User/UserButton.tsx | 31 ++++++++++------------ src/components/User/UserLogin.tsx | 30 ++++++++++++++-------- 3 files changed, 62 insertions(+), 40 deletions(-) diff --git a/src/components/NavBar/NavItems.tsx b/src/components/NavBar/NavItems.tsx index 1756975..5f11f57 100644 --- a/src/components/NavBar/NavItems.tsx +++ b/src/components/NavBar/NavItems.tsx @@ -3,7 +3,7 @@ import UserButton from "components/User/UserButton"; import UserLogin from "components/User/UserLogin"; import UserSignup from "components/User/UserSignup"; import { Colors } from "design/theme"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { useNavigate, Link } from "react-router-dom"; import RoutesEnum from "types/routes.enum"; @@ -14,19 +14,30 @@ const NavItems: React.FC = () => { const [loginOpen, setLoginOpen] = useState(false); const [signupOpen, setSignupOpen] = useState(false); - // TODO: Replace with actual authentication state from your auth context/redux const [isLoggedIn, setIsLoggedIn] = useState(false); const [userName, setUserName] = useState(""); - // for user test - // const isLoggedIn = false; - // const userName = "John Doe"; + // Load user info from localStorage on component mount + useEffect(() => { + const savedUsername = localStorage.getItem("username"); + const savedLoginStatus = localStorage.getItem("isLoggedIn"); - const handleLoginSuccess = (name: string) => { - setUserName(name); + if (savedLoginStatus === "true" && savedUsername) { + setUserName(savedUsername); + setIsLoggedIn(true); + } + }, []); + + const handleLoginSuccess = (username: string) => { + setUserName(username); setIsLoggedIn(true); - // TODO: Store auth token in localStorage or context - // localStorage.setItem('authToken', token); + + // Store user info in localStorage to persist across page refreshes + localStorage.setItem("username", username); + localStorage.setItem("isLoggedIn", "true"); + + // Cookie-based auth: The authentication cookie is automatically sent + // with subsequent requests, so no need to store tokens manually }; const handleSignupSuccess = (name: string) => { @@ -39,8 +50,16 @@ const NavItems: React.FC = () => { const handleLogout = () => { setIsLoggedIn(false); setUserName(""); - // TODO: Clear auth token - // localStorage.removeItem('authToken'); + + // Clear user info from localStorage + localStorage.removeItem("username"); + localStorage.removeItem("isLoggedIn"); + + // Call backend logout endpoint to clear the cookie + fetch("http://localhost:5000/api/v1/auth/logout", { + method: "POST", + credentials: "include", // Send cookies with request + }).catch((err) => console.error("Logout error:", err)); navigate("/"); }; diff --git a/src/components/User/UserButton.tsx b/src/components/User/UserButton.tsx index c6bf3e0..30df7d7 100644 --- a/src/components/User/UserButton.tsx +++ b/src/components/User/UserButton.tsx @@ -3,7 +3,8 @@ import { // PersonAdd, Dashboard, Settings, - ManageAccounts, // Logout, + ManageAccounts, + Logout, } from "@mui/icons-material"; import { IconButton, @@ -93,7 +94,7 @@ const UserButton: React.FC = ({ anchorOrigin={{ horizontal: "right", vertical: "bottom" }} PaperProps={{ sx: { - backgroundColor: Colors.lightBlue, + backgroundColor: Colors.white, color: Colors.darkPurple, minWidth: 200, mt: 1.5, @@ -126,16 +127,12 @@ const UserButton: React.FC = ({ {!isLoggedIn ? ( <> - {/* - - */} Sign In - + - {/* - - */} Create Account @@ -186,26 +183,24 @@ const UserButton: React.FC = ({ Settings - handleMenuItemClick(RoutesEnum.USER_MANAGEMENT)} + {/* handleMenuItemClick(RoutesEnum.USER_MANAGEMENT)} > User Management - + */} - - {/* - - */} + Logout + + + )} diff --git a/src/components/User/UserLogin.tsx b/src/components/User/UserLogin.tsx index eda0a23..41b3d15 100644 --- a/src/components/User/UserLogin.tsx +++ b/src/components/User/UserLogin.tsx @@ -39,19 +39,27 @@ const UserLogin: React.FC = ({ setLoading(true); try { - // TODO: Replace with your actual API call - // const response = await authService.login(email, password); - - // Mock API call (remove this in production) - await new Promise((resolve) => setTimeout(resolve, 1000)); + const response = await fetch("http://localhost:5000/api/v1/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", // Important for cookies + body: JSON.stringify({ + email, + password, + }), + }); + const data = await response.json(); - // Mock successful login - if (email && password) { - onLoginSuccess("John Doe"); // Replace with actual user data from API - handleClose(); - } else { - setError("Please enter both email and password"); + if (!response.ok) { + // Handle error response from backend + throw new Error(data.message || "Login failed"); } + + // Successful login + onLoginSuccess(data.user.username || data.user.email); + handleClose(); } catch (err) { setError("Invalid email or password. Please try again."); } finally { From e0bd72e7ff3a5acdf12f97c2300fd0948fdd8365 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 18 Nov 2025 23:55:02 -0500 Subject: [PATCH 03/46] feat: add auth service and redux integration; refs #111 --- src/App.tsx | 30 +++++++++- src/components/NavBar/NavItems.tsx | 64 ++++++++++++--------- src/components/User/UserLogin.tsx | 78 ++++++++++++++++---------- src/redux/auth/auth.action.ts | 27 +++++++++ src/redux/auth/auth.selector.ts | 3 + src/redux/auth/auth.slice.ts | 62 ++++++++++++++++++++ src/redux/auth/types/auth.interface.ts | 28 +++++++++ src/redux/store.ts | 14 ++++- src/services/auth.service.ts | 41 ++++++++++++++ 9 files changed, 286 insertions(+), 61 deletions(-) create mode 100644 src/redux/auth/auth.action.ts create mode 100644 src/redux/auth/auth.selector.ts create mode 100644 src/redux/auth/auth.slice.ts create mode 100644 src/redux/auth/types/auth.interface.ts create mode 100644 src/services/auth.service.ts diff --git a/src/App.tsx b/src/App.tsx index 9ddbd35..bf77614 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,12 @@ -import { GlobalStyles } from "@mui/material"; +import { GlobalStyles, CircularProgress, Box } from "@mui/material"; import { ThemeProvider } from "@mui/material/styles"; import Routes from "components/Routes"; import theme from "design/theme"; +import { useAppDispatch } from "hooks/useAppDispatch"; import { useGAPageviews } from "hooks/useGAPageviews"; +import { useEffect, useState } from "react"; import { BrowserRouter } from "react-router-dom"; +import { getCurrentUser } from "redux/auth/auth.action"; function GATracker() { useGAPageviews(); @@ -11,6 +14,31 @@ function GATracker() { } const App = () => { + const dispatch = useAppDispatch(); + const [authCheckComplete, setAuthCheckComplete] = useState(false); + + // Check authentication status on app load + useEffect(() => { + dispatch(getCurrentUser()).finally(() => { + setAuthCheckComplete(true); + }); + }, [dispatch]); + + // Show loading spinner while checking authentication + if (!authCheckComplete) { + return ( + + + + + + ); + } return ( { const navigate = useNavigate(); + const dispatch = useAppDispatch(); + + // get auth state from redux + // const auth = useAppSelector((state: RootState) => state.auth); + const auth = useAppSelector(AuthSelector); + const { isLoggedIn, user } = auth; + const userName = user?.username || ""; // Modal state const [loginOpen, setLoginOpen] = useState(false); const [signupOpen, setSignupOpen] = useState(false); - const [isLoggedIn, setIsLoggedIn] = useState(false); - const [userName, setUserName] = useState(""); + // const [isLoggedIn, setIsLoggedIn] = useState(false); + // const [userName, setUserName] = useState(""); // Load user info from localStorage on component mount - useEffect(() => { - const savedUsername = localStorage.getItem("username"); - const savedLoginStatus = localStorage.getItem("isLoggedIn"); + // useEffect(() => { + // const savedUsername = localStorage.getItem("username"); + // const savedLoginStatus = localStorage.getItem("isLoggedIn"); - if (savedLoginStatus === "true" && savedUsername) { - setUserName(savedUsername); - setIsLoggedIn(true); - } - }, []); + // if (savedLoginStatus === "true" && savedUsername) { + // setUserName(savedUsername); + // setIsLoggedIn(true); + // } + // }, []); - const handleLoginSuccess = (username: string) => { - setUserName(username); - setIsLoggedIn(true); + // const handleLoginSuccess = (username: string) => { + // setUserName(username); + // setIsLoggedIn(true); - // Store user info in localStorage to persist across page refreshes - localStorage.setItem("username", username); - localStorage.setItem("isLoggedIn", "true"); + // // Store user info in localStorage to persist across page refreshes + // localStorage.setItem("username", username); + // localStorage.setItem("isLoggedIn", "true"); - // Cookie-based auth: The authentication cookie is automatically sent - // with subsequent requests, so no need to store tokens manually - }; + // // Cookie-based auth: The authentication cookie is automatically sent + // // with subsequent requests, so no need to store tokens manually + // }; const handleSignupSuccess = (name: string) => { - setUserName(name); - setIsLoggedIn(true); + // setUserName(name); + // setIsLoggedIn(true); // TODO: Store auth token in localStorage or context // localStorage.setItem('authToken', token); }; const handleLogout = () => { - setIsLoggedIn(false); - setUserName(""); - - // Clear user info from localStorage - localStorage.removeItem("username"); - localStorage.removeItem("isLoggedIn"); + // setIsLoggedIn(false); + // setUserName(""); // Call backend logout endpoint to clear the cookie fetch("http://localhost:5000/api/v1/auth/logout", { @@ -322,7 +330,7 @@ const NavItems: React.FC = () => { setLoginOpen(false); setSignupOpen(true); }} - onLoginSuccess={handleLoginSuccess} + // onLoginSuccess={handleLoginSuccess} /> void; onSwitchToSignup: () => void; - onLoginSuccess: (userName: string) => void; + // onLoginSuccess: (userName: string) => void; } const UserLogin: React.FC = ({ open, onClose, onSwitchToSignup, - onLoginSuccess, + // onLoginSuccess, }) => { + const dispatch = useAppDispatch(); + const auth = useAppSelector(AuthSelector); + const { loading, error: reduxError } = auth; + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); + // const [loading, setLoading] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); - setLoading(true); - - try { - const response = await fetch("http://localhost:5000/api/v1/auth/login", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - credentials: "include", // Important for cookies - body: JSON.stringify({ - email, - password, - }), - }); - const data = await response.json(); - - if (!response.ok) { - // Handle error response from backend - throw new Error(data.message || "Login failed"); - } - - // Successful login - onLoginSuccess(data.user.username || data.user.email); + dispatch(clearError()); + // setLoading(true); + const result = await dispatch(loginUser({ email, password })); + if (loginUser.fulfilled.match(result)) { + // Success - close modal handleClose(); - } catch (err) { - setError("Invalid email or password. Please try again."); - } finally { - setLoading(false); + } else { + // Error - show in password field + setError(reduxError || "Login failed. Please try again."); } + // try { + // const response = await fetch("http://localhost:5000/api/v1/auth/login", { + // method: "POST", + // headers: { + // "Content-Type": "application/json", + // }, + // credentials: "include", // Important for cookies + // body: JSON.stringify({ + // email, + // password, + // }), + // }); + // const data = await response.json(); + + // if (!response.ok) { + // // Handle error response from backend + // throw new Error(data.message || "Login failed"); + // } + + // // Successful login + // onLoginSuccess(data.user.username || data.user.email); + // handleClose(); + // } catch (err) { + // setError("Invalid email or password. Please try again."); + // } finally { + // setLoading(false); + // } }; const handleClose = () => { @@ -72,6 +89,7 @@ const UserLogin: React.FC = ({ setPassword(""); setError(""); setShowPassword(false); + dispatch(clearError()); // add onClose(); }; diff --git a/src/redux/auth/auth.action.ts b/src/redux/auth/auth.action.ts new file mode 100644 index 0000000..055b7a7 --- /dev/null +++ b/src/redux/auth/auth.action.ts @@ -0,0 +1,27 @@ +import { LoginCredentials, SignupData } from "./types/auth.interface"; +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { AuthService } from "services/auth.service"; + +export const loginUser = createAsyncThunk( + "auth/login", + async (credentials: LoginCredentials, { rejectWithValue }) => { + try { + const response = await AuthService.login(credentials); + return response.user; + } catch (error: any) { + return rejectWithValue(error.message || "Login failed"); + } + } +); + +export const getCurrentUser = createAsyncThunk( + "auth/getCurrentUser", + async (_, { rejectWithValue }) => { + try { + const user = await AuthService.getCurrentUser(); + return user; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to fetch user"); + } + } +); diff --git a/src/redux/auth/auth.selector.ts b/src/redux/auth/auth.selector.ts new file mode 100644 index 0000000..dca694c --- /dev/null +++ b/src/redux/auth/auth.selector.ts @@ -0,0 +1,3 @@ +import { RootState } from "../store"; + +export const AuthSelector = (state: RootState) => state.auth; diff --git a/src/redux/auth/auth.slice.ts b/src/redux/auth/auth.slice.ts new file mode 100644 index 0000000..d0a2b46 --- /dev/null +++ b/src/redux/auth/auth.slice.ts @@ -0,0 +1,62 @@ +import { loginUser, getCurrentUser } from "./auth.action"; +import { IAuthState, User } from "./types/auth.interface"; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +const initialState: IAuthState = { + user: null, + isLoggedIn: false, + loading: false, + error: null, +}; + +const authSlice = createSlice({ + name: "auth", + initialState, + reducers: { + clearError: (state) => { + state.error = null; + }, + }, + extraReducers: (builder) => { + builder + // login + .addCase(loginUser.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(loginUser.fulfilled, (state, action: PayloadAction) => { + state.loading = false; + state.isLoggedIn = true; + state.user = action.payload; + state.error = null; + }) + .addCase(loginUser.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + // Get Current User + .addCase(getCurrentUser.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase( + getCurrentUser.fulfilled, + (state, action: PayloadAction) => { + state.loading = false; + state.isLoggedIn = true; + state.user = action.payload; + state.error = null; + } + ) + .addCase(getCurrentUser.rejected, (state, action) => { + state.loading = false; + state.isLoggedIn = false; + state.user = null; + state.error = action.payload as string; + }); + }, +}); + +export const { clearError } = authSlice.actions; + +export default authSlice.reducer; diff --git a/src/redux/auth/types/auth.interface.ts b/src/redux/auth/types/auth.interface.ts new file mode 100644 index 0000000..2a781af --- /dev/null +++ b/src/redux/auth/types/auth.interface.ts @@ -0,0 +1,28 @@ +export interface User { + id: number; + username: string; + email: string; +} + +export interface LoginCredentials { + email: string; + password: string; +} + +export interface SignupData { + name: string; + email: string; + password: string; +} + +export interface AuthResponse { + message: string; + user: User; +} + +export interface IAuthState { + user: User | null; + isLoggedIn: boolean; + loading: boolean; + error: string | null; +} diff --git a/src/redux/store.ts b/src/redux/store.ts index 2db1cc5..fa6f436 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,11 +1,21 @@ -import { configureStore, combineReducers, createAction, Action } from "@reduxjs/toolkit"; +import authReducer from "./auth/auth.slice"; import neurojsonReducer from "./neurojson/neurojson.slice"; +import { + configureStore, + combineReducers, + createAction, + Action, +} from "@reduxjs/toolkit"; const appReducer = combineReducers({ neurojson: neurojsonReducer, // Add other slices here as needed + auth: authReducer, }); -export const rootReducer = (state: ReturnType | undefined, action: Action) => { +export const rootReducer = ( + state: ReturnType | undefined, + action: Action +) => { if (action.type === "RESET_STATE") { // Reset the Redux state when the RESET_STATE action is dispatched return appReducer(undefined, action); diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 0000000..720f72f --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,41 @@ +import { + AuthResponse, + LoginCredentials, + SignupData, + User, +} from "redux/auth/types/auth.interface"; + +const API_URL = process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1"; + +export const AuthService = { + login: async (credentials: LoginCredentials): Promise => { + const response = await fetch(`${API_URL}/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(credentials), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || "Login failed"); + } + return data; + }, + + getCurrentUser: async (): Promise => { + const response = await fetch(`${API_URL}/auth/me`, { + method: "GET", + credentials: "include", + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to fetch user"); + } + + return data.user; + }, +}; From 4afb58875f86c926734c6d677654cd78b6163753 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 19 Nov 2025 09:55:17 -0500 Subject: [PATCH 04/46] feat: add logout functionality redux integration; refs #111 --- src/components/NavBar/NavItems.tsx | 16 +++++++--------- src/redux/auth/auth.action.ts | 12 ++++++++++++ src/redux/auth/auth.slice.ts | 17 ++++++++++++++++- src/services/auth.service.ts | 11 +++++++++++ 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/components/NavBar/NavItems.tsx b/src/components/NavBar/NavItems.tsx index 10ba3a0..6b65471 100644 --- a/src/components/NavBar/NavItems.tsx +++ b/src/components/NavBar/NavItems.tsx @@ -7,7 +7,7 @@ import { useAppDispatch } from "hooks/useAppDispatch"; import { useAppSelector } from "hooks/useAppSelector"; import React, { useState, useEffect } from "react"; import { useNavigate, Link } from "react-router-dom"; -import { loginUser, getCurrentUser } from "redux/auth/auth.action"; +import { loginUser, getCurrentUser, logoutUser } from "redux/auth/auth.action"; import { AuthSelector } from "redux/auth/auth.selector"; import { RootState } from "redux/store"; import RoutesEnum from "types/routes.enum"; @@ -60,15 +60,13 @@ const NavItems: React.FC = () => { }; const handleLogout = () => { - // setIsLoggedIn(false); - // setUserName(""); - - // Call backend logout endpoint to clear the cookie - fetch("http://localhost:5000/api/v1/auth/logout", { - method: "POST", - credentials: "include", // Send cookies with request - }).catch((err) => console.error("Logout error:", err)); + dispatch(logoutUser()); navigate("/"); + // Call backend logout endpoint to clear the cookie + // fetch("http://localhost:5000/api/v1/auth/logout", { + // method: "POST", + // credentials: "include", // Send cookies with request + // }).catch((err) => console.error("Logout error:", err)); }; return ( diff --git a/src/redux/auth/auth.action.ts b/src/redux/auth/auth.action.ts index 055b7a7..5eb8a39 100644 --- a/src/redux/auth/auth.action.ts +++ b/src/redux/auth/auth.action.ts @@ -25,3 +25,15 @@ export const getCurrentUser = createAsyncThunk( } } ); + +export const logoutUser = createAsyncThunk( + "auth/logout", + async (_, { rejectWithValue }) => { + try { + await AuthService.logout(); + return null; + } catch (error: any) { + return rejectWithValue(error.message || "Logout failed"); + } + } +); diff --git a/src/redux/auth/auth.slice.ts b/src/redux/auth/auth.slice.ts index d0a2b46..21da9a9 100644 --- a/src/redux/auth/auth.slice.ts +++ b/src/redux/auth/auth.slice.ts @@ -1,4 +1,4 @@ -import { loginUser, getCurrentUser } from "./auth.action"; +import { loginUser, getCurrentUser, logoutUser } from "./auth.action"; import { IAuthState, User } from "./types/auth.interface"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; @@ -53,6 +53,21 @@ const authSlice = createSlice({ state.isLoggedIn = false; state.user = null; state.error = action.payload as string; + }) + // Logout + .addCase(logoutUser.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(logoutUser.fulfilled, (state) => { + state.loading = false; + state.isLoggedIn = false; + state.user = null; + state.error = null; + }) + .addCase(logoutUser.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; }); }, }); diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 720f72f..7574d60 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -38,4 +38,15 @@ export const AuthService = { return data.user; }, + + logout: async (): Promise => { + const response = await fetch(`${API_URL}/auth/logout`, { + method: "POST", + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Logout failed"); + } + }, }; From bf367cea30c8d5ac15bda1b1d6547e03a50cfa58 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 19 Nov 2025 11:46:21 -0500 Subject: [PATCH 05/46] feat: integrate user signup feature into redux; refs #111 --- src/components/NavBar/NavItems.tsx | 44 +--------------- src/components/User/UserLogin.tsx | 33 +----------- src/components/User/UserSignup.tsx | 73 ++++++++++++++------------ src/redux/auth/auth.action.ts | 13 +++++ src/redux/auth/auth.slice.ts | 22 +++++++- src/redux/auth/types/auth.interface.ts | 2 +- src/services/auth.service.ts | 18 +++++++ 7 files changed, 93 insertions(+), 112 deletions(-) diff --git a/src/components/NavBar/NavItems.tsx b/src/components/NavBar/NavItems.tsx index 6b65471..ac80f59 100644 --- a/src/components/NavBar/NavItems.tsx +++ b/src/components/NavBar/NavItems.tsx @@ -7,7 +7,7 @@ import { useAppDispatch } from "hooks/useAppDispatch"; import { useAppSelector } from "hooks/useAppSelector"; import React, { useState, useEffect } from "react"; import { useNavigate, Link } from "react-router-dom"; -import { loginUser, getCurrentUser, logoutUser } from "redux/auth/auth.action"; +import { logoutUser } from "redux/auth/auth.action"; import { AuthSelector } from "redux/auth/auth.selector"; import { RootState } from "redux/store"; import RoutesEnum from "types/routes.enum"; @@ -16,8 +16,6 @@ const NavItems: React.FC = () => { const navigate = useNavigate(); const dispatch = useAppDispatch(); - // get auth state from redux - // const auth = useAppSelector((state: RootState) => state.auth); const auth = useAppSelector(AuthSelector); const { isLoggedIn, user } = auth; const userName = user?.username || ""; @@ -26,47 +24,9 @@ const NavItems: React.FC = () => { const [loginOpen, setLoginOpen] = useState(false); const [signupOpen, setSignupOpen] = useState(false); - // const [isLoggedIn, setIsLoggedIn] = useState(false); - // const [userName, setUserName] = useState(""); - - // Load user info from localStorage on component mount - // useEffect(() => { - // const savedUsername = localStorage.getItem("username"); - // const savedLoginStatus = localStorage.getItem("isLoggedIn"); - - // if (savedLoginStatus === "true" && savedUsername) { - // setUserName(savedUsername); - // setIsLoggedIn(true); - // } - // }, []); - - // const handleLoginSuccess = (username: string) => { - // setUserName(username); - // setIsLoggedIn(true); - - // // Store user info in localStorage to persist across page refreshes - // localStorage.setItem("username", username); - // localStorage.setItem("isLoggedIn", "true"); - - // // Cookie-based auth: The authentication cookie is automatically sent - // // with subsequent requests, so no need to store tokens manually - // }; - - const handleSignupSuccess = (name: string) => { - // setUserName(name); - // setIsLoggedIn(true); - // TODO: Store auth token in localStorage or context - // localStorage.setItem('authToken', token); - }; - const handleLogout = () => { dispatch(logoutUser()); navigate("/"); - // Call backend logout endpoint to clear the cookie - // fetch("http://localhost:5000/api/v1/auth/logout", { - // method: "POST", - // credentials: "include", // Send cookies with request - // }).catch((err) => console.error("Logout error:", err)); }; return ( @@ -328,7 +288,6 @@ const NavItems: React.FC = () => { setLoginOpen(false); setSignupOpen(true); }} - // onLoginSuccess={handleLoginSuccess} /> { setSignupOpen(false); setLoginOpen(true); }} - onSignupSuccess={handleSignupSuccess} /> ); diff --git a/src/components/User/UserLogin.tsx b/src/components/User/UserLogin.tsx index 751f7a5..59d8d0d 100644 --- a/src/components/User/UserLogin.tsx +++ b/src/components/User/UserLogin.tsx @@ -23,14 +23,12 @@ interface UserLoginProps { open: boolean; onClose: () => void; onSwitchToSignup: () => void; - // onLoginSuccess: (userName: string) => void; } const UserLogin: React.FC = ({ open, onClose, onSwitchToSignup, - // onLoginSuccess, }) => { const dispatch = useAppDispatch(); const auth = useAppSelector(AuthSelector); @@ -40,13 +38,11 @@ const UserLogin: React.FC = ({ const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(""); - // const [loading, setLoading] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); dispatch(clearError()); - // setLoading(true); const result = await dispatch(loginUser({ email, password })); if (loginUser.fulfilled.match(result)) { // Success - close modal @@ -55,33 +51,6 @@ const UserLogin: React.FC = ({ // Error - show in password field setError(reduxError || "Login failed. Please try again."); } - // try { - // const response = await fetch("http://localhost:5000/api/v1/auth/login", { - // method: "POST", - // headers: { - // "Content-Type": "application/json", - // }, - // credentials: "include", // Important for cookies - // body: JSON.stringify({ - // email, - // password, - // }), - // }); - // const data = await response.json(); - - // if (!response.ok) { - // // Handle error response from backend - // throw new Error(data.message || "Login failed"); - // } - - // // Successful login - // onLoginSuccess(data.user.username || data.user.email); - // handleClose(); - // } catch (err) { - // setError("Invalid email or password. Please try again."); - // } finally { - // setLoading(false); - // } }; const handleClose = () => { @@ -89,7 +58,7 @@ const UserLogin: React.FC = ({ setPassword(""); setError(""); setShowPassword(false); - dispatch(clearError()); // add + dispatch(clearError()); onClose(); }; diff --git a/src/components/User/UserSignup.tsx b/src/components/User/UserSignup.tsx index 0d8970a..790e8b8 100644 --- a/src/components/User/UserSignup.tsx +++ b/src/components/User/UserSignup.tsx @@ -12,23 +12,30 @@ import { Alert, } from "@mui/material"; import { Colors } from "design/theme"; +import { useAppDispatch } from "hooks/useAppDispatch"; +import { useAppSelector } from "hooks/useAppSelector"; import React, { useState } from "react"; +import { signupUser } from "redux/auth/auth.action"; +import { AuthSelector } from "redux/auth/auth.selector"; +import { clearError } from "redux/auth/auth.slice"; interface UserSignupProps { open: boolean; onClose: () => void; onSwitchToLogin: () => void; - onSignupSuccess: (userName: string) => void; } const UserSignup: React.FC = ({ open, onClose, onSwitchToLogin, - onSignupSuccess, }) => { + const dispatch = useAppDispatch(); + const auth = useAppSelector(AuthSelector); + const { loading, error: reduxError } = auth; + const [formData, setFormData] = useState({ - name: "", + username: "", email: "", password: "", confirmPassword: "", @@ -36,7 +43,6 @@ const UserSignup: React.FC = ({ const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); const handleChange = (field: string) => (e: React.ChangeEvent) => { @@ -45,7 +51,7 @@ const UserSignup: React.FC = ({ const validateForm = () => { if ( - !formData.name || + !formData.username || !formData.email || !formData.password || !formData.confirmPassword @@ -73,9 +79,32 @@ const UserSignup: React.FC = ({ return true; }; + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + dispatch(clearError()); + + if (!validateForm()) { + return; + } + + const result = await dispatch( + signupUser({ + username: formData.username, + email: formData.email, + password: formData.password, + }) + ); + if (signupUser.fulfilled.match(result)) { + handleClose(); + } else { + setError(reduxError || "Signup failed. Please try again."); + } + }; + const handleClose = () => { setFormData({ - name: "", + username: "", email: "", password: "", confirmPassword: "", @@ -83,6 +112,7 @@ const UserSignup: React.FC = ({ setError(""); setShowPassword(false); setShowConfirmPassword(false); + dispatch(clearError()); onClose(); }; @@ -91,33 +121,6 @@ const UserSignup: React.FC = ({ onSwitchToLogin(); }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - - if (!validateForm()) { - return; - } - - setLoading(true); - - try { - // TODO: Replace with your actual API call - // const response = await authService.signup(formData); - - // Mock API call (remove this in production) - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Mock successful signup - onSignupSuccess(formData.name); - handleClose(); - } catch (error) { - setError("An error occurred during signup. Please try again."); - } finally { - setLoading(false); - } - }; - return ( = ({ { + try { + console.log("signupdata", signupData); + const response = await AuthService.signup(signupData); + return response.user; + } catch (error: any) { + return rejectWithValue(error.message || "Signup failed"); + } + } +); diff --git a/src/redux/auth/auth.slice.ts b/src/redux/auth/auth.slice.ts index 21da9a9..2bf3887 100644 --- a/src/redux/auth/auth.slice.ts +++ b/src/redux/auth/auth.slice.ts @@ -1,4 +1,9 @@ -import { loginUser, getCurrentUser, logoutUser } from "./auth.action"; +import { + loginUser, + getCurrentUser, + logoutUser, + signupUser, +} from "./auth.action"; import { IAuthState, User } from "./types/auth.interface"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; @@ -68,6 +73,21 @@ const authSlice = createSlice({ .addCase(logoutUser.rejected, (state, action) => { state.loading = false; state.error = action.payload as string; + }) + // Signup + .addCase(signupUser.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(signupUser.fulfilled, (state, action: PayloadAction) => { + state.loading = false; + state.isLoggedIn = true; + state.user = action.payload; + state.error = null; + }) + .addCase(signupUser.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; }); }, }); diff --git a/src/redux/auth/types/auth.interface.ts b/src/redux/auth/types/auth.interface.ts index 2a781af..1065ccf 100644 --- a/src/redux/auth/types/auth.interface.ts +++ b/src/redux/auth/types/auth.interface.ts @@ -10,7 +10,7 @@ export interface LoginCredentials { } export interface SignupData { - name: string; + username: string; email: string; password: string; } diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 7574d60..dbe820d 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -49,4 +49,22 @@ export const AuthService = { throw new Error("Logout failed"); } }, + signup: async (signupData: SignupData): Promise => { + const response = await fetch(`${API_URL}/auth/register`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(signupData), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Signup failed"); + } + + return data; + }, }; From 1d7c14ae9eec5607863249fa67dcb7de39f71e79 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Thu, 20 Nov 2025 10:41:10 -0500 Subject: [PATCH 06/46] feat: add oauth routes and controller, install and configure passport; refs #111 --- backend/config/passport.config.js | 146 ++++++++++++++++++++ backend/package-lock.json | 96 +++++++++++++ backend/package.json | 3 + backend/src/controllers/oauth.controller.js | 76 ++++++++++ backend/src/routes/auth.routes.js | 44 +++++- 5 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 backend/config/passport.config.js create mode 100644 backend/src/controllers/oauth.controller.js diff --git a/backend/config/passport.config.js b/backend/config/passport.config.js new file mode 100644 index 0000000..43ec2ef --- /dev/null +++ b/backend/config/passport.config.js @@ -0,0 +1,146 @@ +// backend/config/passport.config.js +const passport = require("passport"); +const GoogleStrategy = require("passport-google-oauth20").Strategy; +const { User } = require("../models"); + +// Google OAuth Strategy +passport.use( + new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: + process.env.GOOGLE_CALLBACK_URL || + "http://localhost:5000/api/v1/auth/google/callback", + scope: ["profile", "email"], + }, + async (accessToken, refreshToken, profile, done) => { + try { + // Extract user info from Google profile + const email = profile.emails[0].value; + const googleId = profile.id; + const username = profile.displayName || email.split("@")[0]; + + // Check if user already exists with this Google ID + let user = await User.findOne({ + where: { google_id: googleId }, + }); + + if (user) { + // User exists, return user + return done(null, user); + } + + // Check if user exists with this email (linking accounts) + user = await User.findOne({ + where: { email }, + }); + + if (user) { + // User exists with email but no Google ID - link the accounts + user.google_id = googleId; + await user.save(); + return done(null, user); + } + + // Create new user + user = await User.create({ + username: username, + email: email, + google_id: googleId, + hashed_password: null, // OAuth users don't have passwords + }); + + return done(null, user); + } catch (error) { + console.error("Google OAuth error:", error); + return done(error, null); + } + } + ) +); + +// ORCID OAuth Strategy (using OAuth2Strategy as base) +const OAuth2Strategy = require("passport-oauth2"); + +// passport.use( +// "orcid", +// new OAuth2Strategy( +// { +// authorizationURL: "https://orcid.org/oauth/authorize", +// tokenURL: "https://orcid.org/oauth/token", +// clientID: process.env.ORCID_CLIENT_ID, +// clientSecret: process.env.ORCID_CLIENT_SECRET, +// callbackURL: process.env.ORCID_CALLBACK_URL || "http://localhost:5000/api/v1/auth/orcid/callback", +// scope: "/authenticate", +// }, +// async (accessToken, refreshToken, params, profile, done) => { +// try { +// // ORCID returns user info in params, not profile +// const orcidId = params.orcid; +// const name = params.name || `ORCID User ${orcidId}`; + +// // ORCID doesn't always provide email in the basic scope +// // You might need to make an additional API call to get email +// // For now, we'll use ORCID ID as identifier + +// // Check if user exists with this ORCID ID +// let user = await User.findOne({ +// where: { orcid_id: orcidId }, +// }); + +// if (user) { +// return done(null, user); +// } + +// // If we have email from ORCID, check for existing user +// if (params.email) { +// user = await User.findOne({ +// where: { email: params.email }, +// }); + +// if (user) { +// // Link ORCID to existing account +// user.orcid_id = orcidId; +// await user.save(); +// return done(null, user); +// } +// } + +// // Create new user +// // Note: ORCID might not provide email, so we use ORCID ID as part of email +// const email = params.email || `${orcidId}@orcid.placeholder`; +// const username = name.replace(/\s+/g, "_").toLowerCase() || `orcid_${orcidId}`; + +// user = await User.create({ +// username: username, +// email: email, +// orcid_id: orcidId, +// hashed_password: null, +// }); + +// return done(null, user); +// } catch (error) { +// console.error("ORCID OAuth error:", error); +// return done(error, null); +// } +// } +// ) +// ); + +// Serialize user for session +passport.serializeUser((user, done) => { + done(null, user.id); +}); + +// Deserialize user from session +passport.deserializeUser(async (id, done) => { + try { + const user = await User.findByPk(id); + done(null, user); + } catch (error) { + done(error, null); + } +}); + +module.exports = passport; diff --git a/backend/package-lock.json b/backend/package-lock.json index 4989a14..56fd467 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,9 @@ "dotenv": "^17.2.3", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-oauth2": "^1.8.0", "pg": "^8.16.3", "pg-hstore": "^2.3.4", "sequelize": "^6.37.7", @@ -396,6 +399,15 @@ ], "license": "MIT" }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -2490,6 +2502,12 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2564,6 +2582,64 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -2635,6 +2711,11 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -3726,6 +3807,12 @@ "node": ">= 0.6" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/umzug": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/umzug/-/umzug-2.3.0.tgz", @@ -3803,6 +3890,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", diff --git a/backend/package.json b/backend/package.json index ea0dca2..080cf3a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,6 +29,9 @@ "dotenv": "^17.2.3", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", + "passport-oauth2": "^1.8.0", "pg": "^8.16.3", "pg-hstore": "^2.3.4", "sequelize": "^6.37.7", diff --git a/backend/src/controllers/oauth.controller.js b/backend/src/controllers/oauth.controller.js new file mode 100644 index 0000000..29320f0 --- /dev/null +++ b/backend/src/controllers/oauth.controller.js @@ -0,0 +1,76 @@ +const { setTokenCookie } = require("../middleware/auth.middleware"); + +// google oauth initiate +const googleAuth = (req, res, next) => { + // handled by passport middleware + next(); +}; + +// google oauth callback +const googleCallback = (req, res) => { + try { + const user = req.user; + if (!user) { + // OAuth failed, redirect to frontend with error + return res.redirect( + `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/?auth=error&message=Google authentication failed` + ); + } + + // set authentication cookie + setTokenCookie(res, user); + + // redirect to frontend with success + res.redirect( + `${process.env.FRONTEND_URL || "http://localhost:3000"}/?auth=success` + ); + } catch (error) { + console.error("Google callback error:", error); + res.redirect( + `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/?auth=error&message=Authentication failed` + ); + } +}; + +// ORCID OAuth - Initiate +// const orcidAuth = (req, res, next) => { +// // This will be handled by passport middleware +// next(); +// }; + +// // ORCID OAuth - Callback +// const orcidCallback = (req, res) => { +// try { +// const user = req.user; + +// if (!user) { +// return res.redirect( +// `${process.env.FRONTEND_URL || "http://localhost:3000"}/?auth=error&message=ORCID authentication failed` +// ); +// } + +// // Set authentication cookie +// setTokenCookie(res, user); + +// // Redirect to frontend with success +// res.redirect( +// `${process.env.FRONTEND_URL || "http://localhost:3000"}/?auth=success` +// ); +// } catch (error) { +// console.error("ORCID callback error:", error); +// res.redirect( +// `${process.env.FRONTEND_URL || "http://localhost:3000"}/?auth=error&message=Authentication failed` +// ); +// } +// }; + +module.exports = { + googleAuth, + googleCallback, + // orcidAuth, + // orcidCallback, +}; diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index b251668..27bf03b 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -1,12 +1,18 @@ // request send to postgres const express = require("express"); +const passport = require("passport"); const { register, login, getCurrentUser, logout, } = require("../controllers/auth.controller"); -// const { authenticateToken } = require("../middleware/auth.middleware"); +const { + googleAuth, + googleCallback, + // orcidAuth, + // orcidCallback, +} = require("../controllers/oauth.controller"); const { requireAuth } = require("../middleware/auth.middleware"); const router = express.Router(); @@ -16,4 +22,40 @@ router.post("/login", login); router.get("/me", requireAuth, getCurrentUser); router.post("/logout", requireAuth, logout); +// Google OAuth routes +router.get( + "/google", + googleAuth, + passport.authenticate("google", { + scope: ["profile", "email"], + }) +); + +router.get( + "/google/callback", + passport.authenticate("google", { + failureRedirect: `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/?auth=error`, + session: false, // We're using JWT cookies, not sessions + }), + googleCallback +); + +// ORCID OAuth routes +// router.get( +// "/orcid", +// orcidAuth, +// passport.authenticate("orcid") +// ); + +// router.get( +// "/orcid/callback", +// passport.authenticate("orcid", { +// failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:3000"}/?auth=error`, +// session: false, +// }), +// orcidCallback +// ); + module.exports = router; From 8f0c27d1a864ac538db131b3128db837031a535b Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 21 Nov 2025 09:54:09 -0500 Subject: [PATCH 07/46] adjust passport configure file for correct module import --- backend/config/passport.config.js | 3 ++- backend/models/index.js | 41 ++++++++++++++++++------------- backend/src/server.js | 2 ++ 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/backend/config/passport.config.js b/backend/config/passport.config.js index 43ec2ef..3895b66 100644 --- a/backend/config/passport.config.js +++ b/backend/config/passport.config.js @@ -1,7 +1,8 @@ // backend/config/passport.config.js const passport = require("passport"); const GoogleStrategy = require("passport-google-oauth20").Strategy; -const { User } = require("../models"); +// const { User } = require("../models"); +const User = require("../src/models/User"); // Google OAuth Strategy passport.use( diff --git a/backend/models/index.js b/backend/models/index.js index 024200e..b4c77d5 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -1,37 +1,44 @@ -'use strict'; +"use strict"; -const fs = require('fs'); -const path = require('path'); -const Sequelize = require('sequelize'); -const process = require('process'); +const fs = require("fs"); +const path = require("path"); +const Sequelize = require("sequelize"); +const process = require("process"); const basename = path.basename(__filename); -const env = process.env.NODE_ENV || 'development'; -const config = require(__dirname + '/../config/config.json')[env]; +const env = process.env.NODE_ENV || "development"; +const config = require(__dirname + "/../config/config.js")[env]; const db = {}; let sequelize; if (config.use_env_variable) { sequelize = new Sequelize(process.env[config.use_env_variable], config); } else { - sequelize = new Sequelize(config.database, config.username, config.password, config); + sequelize = new Sequelize( + config.database, + config.username, + config.password, + config + ); } -fs - .readdirSync(__dirname) - .filter(file => { +fs.readdirSync(__dirname) + .filter((file) => { return ( - file.indexOf('.') !== 0 && + file.indexOf(".") !== 0 && file !== basename && - file.slice(-3) === '.js' && - file.indexOf('.test.js') === -1 + file.slice(-3) === ".js" && + file.indexOf(".test.js") === -1 ); }) - .forEach(file => { - const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes); + .forEach((file) => { + const model = require(path.join(__dirname, file))( + sequelize, + Sequelize.DataTypes + ); db[model.name] = model; }); -Object.keys(db).forEach(modelName => { +Object.keys(db).forEach((modelName) => { if (db[modelName].associate) { db[modelName].associate(db); } diff --git a/backend/src/server.js b/backend/src/server.js index 2d6767f..bd80192 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -2,6 +2,7 @@ const express = require("express"); const cors = require("cors"); const cookieParser = require("cookie-parser"); require("dotenv").config(); +const passport = require("../config/passport.config"); const { connectDatabase, sequelize } = require("./config/database"); const { restoreUser } = require("./middleware/auth.middleware"); @@ -24,6 +25,7 @@ app.use( app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); // parse cookies +app.use(passport.initialize()); // restore user on every request app.use(restoreUser); From 314e4e9e4f8af2b22bc875e4c92440048bfeca3a Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 21 Nov 2025 10:40:06 -0500 Subject: [PATCH 08/46] feat: add sign in with google button; refs #111 --- public/img/user/web_light_sq_SI@2x.png | Bin 0 -> 4526 bytes public/img/user/web_light_sq_SU@2x.png | Bin 0 -> 4673 bytes src/components/User/GoogleButton.tsx | 42 +++++++++++++++++++++++++ src/components/User/UserLogin.tsx | 19 +++++++++++ 4 files changed, 61 insertions(+) create mode 100644 public/img/user/web_light_sq_SI@2x.png create mode 100644 public/img/user/web_light_sq_SU@2x.png create mode 100644 src/components/User/GoogleButton.tsx diff --git a/public/img/user/web_light_sq_SI@2x.png b/public/img/user/web_light_sq_SI@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f03378374d31603d13e2355c1b5ce803ae90cd42 GIT binary patch literal 4526 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 literal 0 HcmV?d00001 diff --git a/public/img/user/web_light_sq_SU@2x.png b/public/img/user/web_light_sq_SU@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fb3a3a24fad0d042ac02ef4e0947aa4a550bd748 GIT binary patch literal 4673 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 void; + disabled?: boolean; + variant?: "signin" | "signup"; +} + +const GoogleButton: React.FC = ({ + onClick, + disabled = false, + variant = "signin", +}) => { + const imageSrc = + variant === "signin" + ? "/img/user/web_light_sq_SI@2x.png" + : "/img/user/web_light_sq_SU@2x.png"; + + return ( + + ); +}; + +export default GoogleButton; diff --git a/src/components/User/UserLogin.tsx b/src/components/User/UserLogin.tsx index 59d8d0d..c7d1946 100644 --- a/src/components/User/UserLogin.tsx +++ b/src/components/User/UserLogin.tsx @@ -1,3 +1,4 @@ +import GoogleButton from "./GoogleButton"; import { Close, Visibility, VisibilityOff } from "@mui/icons-material"; import { Dialog, @@ -39,6 +40,11 @@ const UserLogin: React.FC = ({ const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(""); + const handleOAuthLogin = (provider: "google" | "orcid") => { + const apiUrl = process.env.REACT_APP_API_URL || "http://localhost:5000"; + window.location.href = `${apiUrl}/api/v1/auth/${provider}`; + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); @@ -66,6 +72,7 @@ const UserLogin: React.FC = ({ handleClose(); onSwitchToSignup(); }; + return ( = ({ > {loading ? "Sign In..." : "Sign In"} + + handleOAuthLogin("google")} + disabled={loading} + /> + {/* handleOAuthLogin("orcid")} + disabled={loading} + /> */} + Don't have an account?{" "} From 5daa4816bf5d174758b63b717c820c503e738180 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 24 Nov 2025 10:30:28 -0500 Subject: [PATCH 09/46] fix: add AuthHandler component to prevent cached login state from displaying after log out; refs #111 --- src/App.tsx | 44 +++++++++++++++++++++++++++- src/components/User/GoogleButton.tsx | 12 ++++++-- src/components/User/UserSignup.tsx | 18 ++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index bf77614..29da3ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import theme from "design/theme"; import { useAppDispatch } from "hooks/useAppDispatch"; import { useGAPageviews } from "hooks/useGAPageviews"; import { useEffect, useState } from "react"; -import { BrowserRouter } from "react-router-dom"; +import { BrowserRouter, useLocation } from "react-router-dom"; import { getCurrentUser } from "redux/auth/auth.action"; function GATracker() { @@ -13,6 +13,47 @@ function GATracker() { return null; } +// Component to handle auth checks within Router context +// AuthHandler starts listening for: +// - Browser back/forward +// - OAuth callbacks (first login via OAuth) +function AuthHandler() { + const dispatch = useAppDispatch(); + const location = useLocation(); + + // Handle browser back/forward navigation + useEffect(() => { + const handlePopState = () => { + dispatch(getCurrentUser()); + }; + + window.addEventListener("popstate", handlePopState); + + return () => { + window.removeEventListener("popstate", handlePopState); + }; + }, [dispatch]); + + // Handle OAuth callback + useEffect(() => { + const searchParams = new URLSearchParams(location.search); + const authStatus = searchParams.get("auth"); + + if (authStatus === "success") { + dispatch(getCurrentUser()); + // Clean up URL + window.history.replaceState({}, "", window.location.pathname); + } else if (authStatus === "error") { + const errorMessage = + searchParams.get("message") || "Authentication failed"; + // Clean up URL + window.history.replaceState({}, "", window.location.pathname); + } + }, [location.search, dispatch]); + + return null; +} + const App = () => { const dispatch = useAppDispatch(); const [authCheckComplete, setAuthCheckComplete] = useState(false); @@ -50,6 +91,7 @@ const App = () => { /> + diff --git a/src/components/User/GoogleButton.tsx b/src/components/User/GoogleButton.tsx index b8f0bdf..8825baf 100644 --- a/src/components/User/GoogleButton.tsx +++ b/src/components/User/GoogleButton.tsx @@ -25,15 +25,21 @@ const GoogleButton: React.FC = ({ onClick={disabled ? undefined : onClick} sx={{ width: "100%", - maxWidth: "382px", - height: "auto", + height: "48px", + objectFit: "contain", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, - transition: "opacity 0.2s", + transition: "opacity 0.2s, transform 0.1s", "&:hover": { opacity: disabled ? 0.5 : 0.9, + transform: disabled ? "none" : "translateY(-1px)", + }, + "&:active": { + transform: disabled ? "none" : "translateY(0)", }, mb: 1.5, + borderRadius: "4px", + display: "block", }} /> ); diff --git a/src/components/User/UserSignup.tsx b/src/components/User/UserSignup.tsx index 790e8b8..e46a641 100644 --- a/src/components/User/UserSignup.tsx +++ b/src/components/User/UserSignup.tsx @@ -1,3 +1,4 @@ +import GoogleButton from "./GoogleButton"; import { Close, Visibility, VisibilityOff } from "@mui/icons-material"; import { Dialog, @@ -44,6 +45,11 @@ const UserSignup: React.FC = ({ const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [error, setError] = useState(""); + const handleOAuthSignup = (provider: "google" | "orcid") => { + const apiUrl = process.env.REACT_APP_API_URL || "http://localhost:5000"; + window.location.href = `${apiUrl}/api/v1/auth/${provider}`; + }; + const handleChange = (field: string) => (e: React.ChangeEvent) => { setFormData({ ...formData, [field]: e.target.value }); @@ -297,6 +303,18 @@ const UserSignup: React.FC = ({ > {loading ? "Creating Account..." : "Create Account"} + + handleOAuthSignup("google")} + disabled={loading} + /> + {/* handleOAuthSignup("orcid")} + disabled={loading} + /> */} + Already have an account?{" "} From 210e2586b8ede6cf79206596a9e37f230997e867 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 24 Nov 2025 12:06:50 -0500 Subject: [PATCH 10/46] feat: create custom button for continue with google button; refs #111 --- public/img/user/web_light_sq_SI@2x.png | Bin 4526 -> 0 bytes public/img/user/web_light_sq_SU@2x.png | Bin 4673 -> 0 bytes src/components/User/GoogleButton.tsx | 68 ++++++++++++++----------- src/components/User/UserLogin.tsx | 2 +- src/components/User/UserSignup.tsx | 2 +- 5 files changed, 41 insertions(+), 31 deletions(-) delete mode 100644 public/img/user/web_light_sq_SI@2x.png delete mode 100644 public/img/user/web_light_sq_SU@2x.png diff --git a/public/img/user/web_light_sq_SI@2x.png b/public/img/user/web_light_sq_SI@2x.png deleted file mode 100644 index f03378374d31603d13e2355c1b5ce803ae90cd42..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4526 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 diff --git a/public/img/user/web_light_sq_SU@2x.png b/public/img/user/web_light_sq_SU@2x.png deleted file mode 100644 index fb3a3a24fad0d042ac02ef4e0947aa4a550bd748..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4673 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 void; disabled?: boolean; - variant?: "signin" | "signup"; + // variant?: "signin" | "signup"; } -const GoogleButton: React.FC = ({ +const GoogleButton: React.FC = ({ onClick, disabled = false, - variant = "signin", + // variant = "signin", }) => { - const imageSrc = - variant === "signin" - ? "/img/user/web_light_sq_SI@2x.png" - : "/img/user/web_light_sq_SU@2x.png"; - return ( - + > + {/* Google Logo */} + + Continue with Google + ); }; diff --git a/src/components/User/UserLogin.tsx b/src/components/User/UserLogin.tsx index c7d1946..269bc84 100644 --- a/src/components/User/UserLogin.tsx +++ b/src/components/User/UserLogin.tsx @@ -195,7 +195,7 @@ const UserLogin: React.FC = ({ handleOAuthLogin("google")} disabled={loading} /> diff --git a/src/components/User/UserSignup.tsx b/src/components/User/UserSignup.tsx index e46a641..dfe5029 100644 --- a/src/components/User/UserSignup.tsx +++ b/src/components/User/UserSignup.tsx @@ -305,7 +305,7 @@ const UserSignup: React.FC = ({ handleOAuthSignup("google")} disabled={loading} /> From fc4a5335dfdc64ffe30685503157b6d9e6a3b9ed Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 24 Nov 2025 17:10:07 -0500 Subject: [PATCH 11/46] feat: update user panel when user log in; refs #111 --- src/components/User/UserButton.tsx | 43 +++++++++++++++++++----------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/components/User/UserButton.tsx b/src/components/User/UserButton.tsx index 30df7d7..c15ca6c 100644 --- a/src/components/User/UserButton.tsx +++ b/src/components/User/UserButton.tsx @@ -1,12 +1,13 @@ import { - AccountCircle, // Login, - // PersonAdd, + AccountCircle, Dashboard, Settings, ManageAccounts, Logout, } from "@mui/icons-material"; import { + Box, + Typography, IconButton, Menu, MenuItem, @@ -141,25 +142,35 @@ const UserButton: React.FC = ({ <> {userName && ( <> - - + Welcome, + + - + > + {userName} + + From 44bf3bcc1ec1f36da807c025f8a19cce39278c29 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 25 Nov 2025 11:03:08 -0500 Subject: [PATCH 12/46] fix: adjust app.tsx to listen to event persisted --- src/App.tsx | 54 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 29da3ce..6fc171d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,9 @@ import theme from "design/theme"; import { useAppDispatch } from "hooks/useAppDispatch"; import { useGAPageviews } from "hooks/useGAPageviews"; import { useEffect, useState } from "react"; +import React from "react"; import { BrowserRouter, useLocation } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { getCurrentUser } from "redux/auth/auth.action"; function GATracker() { @@ -17,14 +19,25 @@ function GATracker() { // AuthHandler starts listening for: // - Browser back/forward // - OAuth callbacks (first login via OAuth) +// - Page restore from back-forward cache (bfcache) function AuthHandler() { const dispatch = useAppDispatch(); const location = useLocation(); + const navigate = useNavigate(); + const hasProcessedOAuthRef = React.useRef(false); // Handle browser back/forward navigation useEffect(() => { const handlePopState = () => { + console.log("🔄 Browser navigation detected"); dispatch(getCurrentUser()); + + // If user navigated back to an OAuth callback URL, redirect to home + const searchParams = new URLSearchParams(window.location.search); + if (searchParams.get("auth")) { + console.log("⚠️ Back to OAuth URL - redirecting to home"); + navigate("/", { replace: true }); + } }; window.addEventListener("popstate", handlePopState); @@ -32,6 +45,20 @@ function AuthHandler() { return () => { window.removeEventListener("popstate", handlePopState); }; + }, [dispatch, navigate]); + + // 🔁 Re-check auth when page is restored from back-forward cache (bfcache) + useEffect(() => { + const handlePageShow = (event: PageTransitionEvent) => { + // event.persisted === true when coming from bfcache + if (event.persisted) { + console.log("🔁 Page restored from bfcache - revalidating auth"); + dispatch(getCurrentUser()); + } + }; + + window.addEventListener("pageshow", handlePageShow); + return () => window.removeEventListener("pageshow", handlePageShow); }, [dispatch]); // Handle OAuth callback @@ -39,17 +66,24 @@ function AuthHandler() { const searchParams = new URLSearchParams(location.search); const authStatus = searchParams.get("auth"); - if (authStatus === "success") { - dispatch(getCurrentUser()); - // Clean up URL - window.history.replaceState({}, "", window.location.pathname); - } else if (authStatus === "error") { - const errorMessage = - searchParams.get("message") || "Authentication failed"; - // Clean up URL - window.history.replaceState({}, "", window.location.pathname); + // Only process OAuth callback once per session + if (authStatus && !hasProcessedOAuthRef.current) { + hasProcessedOAuthRef.current = true; // Mark as processed + + if (authStatus === "success") { + console.log("✅ OAuth success - fetching user"); + dispatch(getCurrentUser()); + } else if (authStatus === "error") { + const errorMessage = + searchParams.get("message") || "Authentication failed"; + console.error("❌ OAuth error:", errorMessage); + } + + // Clean up URL - this removes it from history + // window.history.replaceState({}, "", window.location.pathname); + navigate(window.location.pathname, { replace: true }); } - }, [location.search, dispatch]); + }, [location.search, dispatch, navigate]); return null; } From 2bb8f0c9c2769d86f5db49854272e1fe6943ef2b Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Thu, 4 Dec 2025 15:20:16 -0500 Subject: [PATCH 13/46] feat: add email verification service file, modify user model and migration files --- backend/config/passport.config.js | 2 + .../migrations/20251028184256-create-users.js | 14 ++ backend/package-lock.json | 10 + backend/package.json | 1 + backend/services/email.service.js | 177 ++++++++++++++++++ backend/src/controllers/auth.controller.js | 84 +++++++++ backend/src/models/User.js | 59 +++++- src/App.tsx | 7 +- 8 files changed, 344 insertions(+), 10 deletions(-) create mode 100644 backend/services/email.service.js diff --git a/backend/config/passport.config.js b/backend/config/passport.config.js index 3895b66..2ff85e2 100644 --- a/backend/config/passport.config.js +++ b/backend/config/passport.config.js @@ -50,6 +50,7 @@ passport.use( email: email, google_id: googleId, hashed_password: null, // OAuth users don't have passwords + email_verified: true, }); return done(null, user); @@ -118,6 +119,7 @@ const OAuth2Strategy = require("passport-oauth2"); // email: email, // orcid_id: orcidId, // hashed_password: null, +// email_verified: true, // }); // return done(null, user); diff --git a/backend/migrations/20251028184256-create-users.js b/backend/migrations/20251028184256-create-users.js index 7ff7f25..cf8d4ff 100644 --- a/backend/migrations/20251028184256-create-users.js +++ b/backend/migrations/20251028184256-create-users.js @@ -45,6 +45,20 @@ module.exports = { allowNull: false, unique: true, }, + email_verified: { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false, + }, + verification_token: { + type: Sequelize.STRING(255), + allowNull: true, + unique: true, + }, + verification_token_expires: { + type: Sequelize.DATE, + allowNull: true, + }, created_at: { type: Sequelize.DATE, allowNull: false, diff --git a/backend/package-lock.json b/backend/package-lock.json index 56fd467..423ee9c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,7 @@ "dotenv": "^17.2.3", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", + "nodemailer": "^7.0.11", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-oauth2": "^1.8.0", @@ -2430,6 +2431,15 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", diff --git a/backend/package.json b/backend/package.json index 080cf3a..d4d9b83 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,6 +29,7 @@ "dotenv": "^17.2.3", "express": "^5.1.0", "jsonwebtoken": "^9.0.2", + "nodemailer": "^7.0.11", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-oauth2": "^1.8.0", diff --git a/backend/services/email.service.js b/backend/services/email.service.js new file mode 100644 index 0000000..7ca6bc1 --- /dev/null +++ b/backend/services/email.service.js @@ -0,0 +1,177 @@ +const nodemailer = require("nodemailer"); + +class EmailService { + constructor() { + // Create transporter based on environment + if (process.env.NODE_ENV === "production") { + // Production: Use real SMTP + this.transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: process.env.SMTP_PORT || 587, + secure: process.env.SMTP_SECURE === "true", + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + } else { + // Development: Use Ethereal (fake SMTP for testing) + this.createTestTransporter(); + } + } + + async createTestTransporter() { + const testAccount = await nodemailer.createTestAccount(); + this.transporter = nodemailer.createTransport({ + host: "smtp.ethereal.email", + port: 587, + secure: false, + auth: { + user: testAccount.user, + pass: testAccount.pass, + }, + }); + console.log("📧 Using Ethereal email for development"); + console.log("Preview emails at: https://ethereal.email"); + } + + async sendVerificationEmail(user, token) { + const verificationUrl = `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/verify-email?token=${token}`; + + const mailOptions = { + from: `"NeuroJSON.io" <${ + process.env.SMTP_FROM || "noreply@neurojson.io" + }>`, + to: user.email, + subject: "Verify Your Email - NeuroJSON.io", + html: this.getVerificationEmailTemplate(user.username, verificationUrl), + text: `Hi ${user.username},\n\nPlease verify your email by clicking this link:\n${verificationUrl}\n\nThis link will expire in 24 hours.\n\nBest regards,\nThe NeuroJSON.io Team`, + }; + + try { + const info = await this.transporter.sendMail(mailOptions); + + if (process.env.NODE_ENV !== "production") { + console.log("📧 Verification email sent!"); + console.log("Preview URL:", nodemailer.getTestMessageUrl(info)); + } + + return { success: true, messageId: info.messageId }; + } catch (error) { + console.error("Email sending error:", error); + throw new Error("Failed to send verification email"); + } + } + + async sendWelcomeEmail(user) { + const mailOptions = { + from: `"NeuroJSON.io" <${ + process.env.SMTP_FROM || "noreply@neurojson.io" + }>`, + to: user.email, + subject: "Welcome to NeuroJSON.io! 🎉", + html: this.getWelcomeEmailTemplate(user.username), + text: `Hi ${user.username},\n\nWelcome to NeuroJSON.io! Your email has been verified.\n\nBest regards,\nThe NeuroJSON.io Team`, + }; + + try { + await this.transporter.sendMail(mailOptions); + return { success: true }; + } catch (error) { + console.error("Welcome email error:", error); + return { success: false }; + } + } + + getVerificationEmailTemplate(username, verificationUrl) { + return ` + + + + + + + +
+
+

NeuroJSON.io

+

Free Data Worth Sharing

+
+
+

Hi ${username}! 👋

+

Thank you for signing up for NeuroJSON.io! We're excited to have you join our community.

+

Please verify your email address by clicking the button below:

+ +

Or copy and paste this link:

+

${verificationUrl}

+

This link will expire in 24 hours.

+

If you didn't create an account, you can safely ignore this email.

+
+ +
+ + + `; + } + + getWelcomeEmailTemplate(username) { + return ` + + + + + + + +
+
+

🎉 Welcome to NeuroJSON.io!

+
+
+

Hi ${username}!

+

Your email has been verified successfully! Welcome to the NeuroJSON.io community.

+

You can now:

+
    +
  • Browse and search neuroscience datasets
  • +
  • Upload and share your own data
  • +
  • Save and like datasets
  • +
  • Comment and collaborate
  • +
+ +

Happy researching! 🧠

+
+ +
+ + + `; + } +} + +module.exports = new EmailService(); diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 19b4735..8e55662 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -2,6 +2,7 @@ const jwt = require("jsonwebtoken"); const { User } = require("../models"); const bcrypt = require("bcrypt"); const { setTokenCookie } = require("../middleware/auth.middleware"); +const emailService = require("../../services/email.service"); const JWT_SECRET = process.env.JWT_SECRET; @@ -77,6 +78,7 @@ const register = async (req, res) => { orcid_id: orcid_id || null, google_id: google_id || null, github_id: github_id || null, + email_verified: isOAuthSignup ? true : false, // OAuth users auto-verified }); // generate JWT token @@ -84,6 +86,31 @@ const register = async (req, res) => { // expiresIn: "1h", // }); + // NEW: For traditional signup, send verification email + if (!isOAuthSignup) { + const verificationToken = user.generateVerificationToken(); + await user.save(); + + try { + await emailService.sendVerificationEmail(user, verificationToken); + } catch (emailError) { + console.error("Failed to send verification email:", emailError); + // Don't fail registration if email fails + } + + return res.status(201).json({ + message: + "Registration successful! Please check your email to verify your account.", + user: { + id: user.id, + username: user.username, + email: user.email, + email_verified: user.email_verified, + }, + requiresVerification: true, + }); + } + //set suthentication cookie setTokenCookie(res, user); @@ -163,6 +190,15 @@ const login = async (req, res) => { // const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { // expiresIn: "1h", // }); + // NEW: Check if email is verified + if (!user.email_verified) { + return res.status(403).json({ + message: + "Please verify your email before logging in. Check your inbox for the verification link.", + requiresVerification: true, + email: user.email, + }); + } // set authentication cookie setTokenCookie(res, user); @@ -174,6 +210,7 @@ const login = async (req, res) => { id: user.id, username: user.username, email: user.email, + email_verified: user.email_verified, }, }); } catch (error) { @@ -199,6 +236,7 @@ const getCurrentUser = async (req, res) => { id: user.id, username: user.username, email: user.email, + email_verified: user.email_verified, // NEW created_at: user.created_at, updated_at: user.updated_at, }, @@ -255,9 +293,55 @@ const logout = async (req, res) => { } }; +// Resend verification email +const resendVerificationEmail = async (req, res) => { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ + message: "Email is required", + }); + } + + const user = await User.findOne({ where: { email } }); + + if (!user) { + // Don't reveal if email exists + return res.status(200).json({ + message: + "If this email exists and is unverified, a verification email has been sent.", + }); + } + + if (user.email_verified) { + return res.status(400).json({ + message: "This email is already verified", + }); + } + + // Generate new token + const verificationToken = user.generateVerificationToken(); + await user.save(); + + await emailService.sendVerificationEmail(user, verificationToken); + + res.status(200).json({ + message: "Verification email sent. Please check your inbox.", + }); + } catch (error) { + console.error("Resend verification error:", error); + res.status(500).json({ + message: "Error sending verification email", + error: error.message, + }); + } +}; + module.exports = { register, login, getCurrentUser, logout, + resendVerificationEmail, // NEW }; diff --git a/backend/src/models/User.js b/backend/src/models/User.js index a79431d..1f1b843 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -1,16 +1,53 @@ const { DataTypes, Model } = require("sequelize"); const { sequelize } = require("../config/database"); const bcrypt = require("bcrypt"); +const crypto = require("crypto"); class User extends Model { async comparePassword(password) { return bcrypt.compare(password, this.hashed_password); } - // async hashPassword(password) { - // const salt = await bcrypt.genSalt(10); - // this.hashed_password = await bcrypt.hash(password, salt); - // } + // Generate email verification token + generateVerificationToken() { + // Create random token + const token = crypto.randomBytes(32).toString("hex"); + + // Hash it before storing (security best practice) + this.verification_token = crypto + .createHash("sha256") + .update(token) + .digest("hex"); + + // Set expiration (24 hours) + this.verification_token_expires = new Date( + Date.now() + 24 * 60 * 60 * 1000 + ); + + // Return unhashed token to send in email + return token; + } + // Verify if token is valid + isVerificationTokenValid(token) { + if (!this.verification_token || !this.verification_token_expires) { + return false; // no token exists + } + + // Hash provided token to compare + const hashedToken = crypto.createHash("sha256").update(token).digest("hex"); + + // Check if matches and not expired + return ( + hashedToken === this.verification_token && + this.verification_token_expires > new Date() + ); + } + + // Clear verification token + clearVerificationToken() { + this.verification_token = null; + this.verification_token_expires = null; + } } User.init( @@ -52,6 +89,20 @@ User.init( isEmail: true, }, }, + email_verified: { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false, + }, + verification_token: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + verification_token_expires: { + type: DataTypes.DATE, + allowNull: true, + }, created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW, diff --git a/src/App.tsx b/src/App.tsx index 6fc171d..d2a2469 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,13 +29,11 @@ function AuthHandler() { // Handle browser back/forward navigation useEffect(() => { const handlePopState = () => { - console.log("🔄 Browser navigation detected"); dispatch(getCurrentUser()); // If user navigated back to an OAuth callback URL, redirect to home const searchParams = new URLSearchParams(window.location.search); if (searchParams.get("auth")) { - console.log("⚠️ Back to OAuth URL - redirecting to home"); navigate("/", { replace: true }); } }; @@ -47,12 +45,11 @@ function AuthHandler() { }; }, [dispatch, navigate]); - // 🔁 Re-check auth when page is restored from back-forward cache (bfcache) + // Re-check auth when page is restored from back-forward cache (bfcache) useEffect(() => { const handlePageShow = (event: PageTransitionEvent) => { // event.persisted === true when coming from bfcache if (event.persisted) { - console.log("🔁 Page restored from bfcache - revalidating auth"); dispatch(getCurrentUser()); } }; @@ -71,12 +68,10 @@ function AuthHandler() { hasProcessedOAuthRef.current = true; // Mark as processed if (authStatus === "success") { - console.log("✅ OAuth success - fetching user"); dispatch(getCurrentUser()); } else if (authStatus === "error") { const errorMessage = searchParams.get("message") || "Authentication failed"; - console.error("❌ OAuth error:", errorMessage); } // Clean up URL - this removes it from history From 16db515e71d8a5e999a48ef7d126ca54bb185396 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 9 Dec 2025 15:06:41 -0500 Subject: [PATCH 14/46] feat: add email verification controller to verify email --- .../controllers/verification.controller.js | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 backend/src/controllers/verification.controller.js diff --git a/backend/src/controllers/verification.controller.js b/backend/src/controllers/verification.controller.js new file mode 100644 index 0000000..f55ffc0 --- /dev/null +++ b/backend/src/controllers/verification.controller.js @@ -0,0 +1,80 @@ +const { User } = require("../models"); +const { setTokenCookie } = require("../middleware/auth.middleware"); +const emailService = require("../../services/email.service"); +const crypto = require("crypto"); + +// verify email with token +const verifyEmail = async (req, res) => { + try { + const { token } = req.query; + if (!token) { + return res.status(400).json({ + message: "Verification token is required", + }); + } + + // hash token to compare + const hashedToken = crypto.createHash("sha256").update(token).digest("hex"); + + // find user with this token + const user = await User.findOne({ + where: { + verification_token: hashedToken, + }, + }); + + if (!user) { + return res.status(400).json({ + message: "Invalid or expired verification token", + }); + } + + // check if already verified + if (user.email_verified) { + return res.status(400).json({ + message: "Email is already verified", + }); + } + + // Check if token is valid and not expired + if (!user.isVerificationTokenValid(token)) { + return res.status(400).json({ + message: "Verification token has expired. Please request a new one.", + }); + } + // Actually verify the email and clear token + user.email_verified = true; // Mark as verified + user.clearVerificationToken(); // Remove token (one-time use) + await user.save(); // Save to database + + // send welcome email + try { + await emailService.sendWelcomeEmail(user); + } catch (emailError) { + console.error("Failed to send welcome email:", emailError); + } + + // log the user in + setTokenCookie(res, user); + + res.status(200).json({ + message: "Email verified successfully! Welcome to NeuroJSON.io", + user: { + id: user.id, + username: user.username, + email: user.email, + email_verified: user.email_verified, + }, + }); + } catch (error) { + console.error("Email verification error:", error); + res.status(500).json({ + message: "Error verifying email", + error: error.message, + }); + } +}; + +module.exports = { + verifyEmail, +}; From 14921484e567edf4e6b75c0bed59564ea7fbca44 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 9 Dec 2025 15:13:03 -0500 Subject: [PATCH 15/46] feat: add email verification routes --- backend/src/routes/auth.routes.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index 27bf03b..93f777e 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -6,7 +6,9 @@ const { login, getCurrentUser, logout, + resendVerificationEmail, } = require("../controllers/auth.controller"); +const { verifyEmail } = require("../controllers/verification.controller"); const { googleAuth, googleCallback, @@ -17,11 +19,16 @@ const { requireAuth } = require("../middleware/auth.middleware"); const router = express.Router(); +// traditional authentication routes router.post("/register", register); router.post("/login", login); router.get("/me", requireAuth, getCurrentUser); router.post("/logout", requireAuth, logout); +// email verification routes +router.get("/verify-email", verifyEmail); +router.post("resend-verification", resendVerificationEmail); + // Google OAuth routes router.get( "/google", From 792fe9f248f9b02477e24061217511342e7b6899 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 9 Dec 2025 16:41:34 -0500 Subject: [PATCH 16/46] feat: create a verify email page --- .../controllers/verification.controller.js | 5 + src/pages/VerifyEmail.tsx | 189 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/pages/VerifyEmail.tsx diff --git a/backend/src/controllers/verification.controller.js b/backend/src/controllers/verification.controller.js index f55ffc0..d0a9f38 100644 --- a/backend/src/controllers/verification.controller.js +++ b/backend/src/controllers/verification.controller.js @@ -10,6 +10,7 @@ const verifyEmail = async (req, res) => { if (!token) { return res.status(400).json({ message: "Verification token is required", + expired: false, }); } @@ -26,6 +27,7 @@ const verifyEmail = async (req, res) => { if (!user) { return res.status(400).json({ message: "Invalid or expired verification token", + expired: false, }); } @@ -33,6 +35,7 @@ const verifyEmail = async (req, res) => { if (user.email_verified) { return res.status(400).json({ message: "Email is already verified", + expired: false, }); } @@ -40,6 +43,7 @@ const verifyEmail = async (req, res) => { if (!user.isVerificationTokenValid(token)) { return res.status(400).json({ message: "Verification token has expired. Please request a new one.", + expired: true, }); } // Actually verify the email and clear token @@ -71,6 +75,7 @@ const verifyEmail = async (req, res) => { res.status(500).json({ message: "Error verifying email", error: error.message, + expired: false, }); } }; diff --git a/src/pages/VerifyEmail.tsx b/src/pages/VerifyEmail.tsx new file mode 100644 index 0000000..f8c8eb2 --- /dev/null +++ b/src/pages/VerifyEmail.tsx @@ -0,0 +1,189 @@ +import { CheckCircle, Error } from "@mui/icons-material"; +import { + Box, + Container, + Typography, + CircularProgress, + Button, + Alert, + Paper, +} from "@mui/material"; +import { Colors } from "design/theme"; +import { useAppDispatch } from "hooks/useAppDispatch"; +import React, { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { getCurrentUser } from "redux/auth/auth.action"; + +const VerifyEmail: React.FC = () => { + const [searchParams] = useSearchParams(); // don't need setSearchParams + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + + const [status, setStatus] = useState<"loading" | "success" | "error">( + "loading" + ); + const [message, setMessage] = useState(""); + const [isExpired, setIsExpired] = useState(false); + + useEffect(() => { + const verifyEmail = async () => { + const token = searchParams.get("token"); + if (!token) { + setStatus("error"); + setMessage("No verification token provided"); + return; + } + try { + const response = await fetch( + `${ + process.env.REACT_APP_API_URL || "http://localhost:5000" + }/api/v1/auth/verify-email?token=${token}`, + { + method: "GET", + credentials: "include", + } + ); + + const data = await response.json(); + + if (response.ok) { + setStatus("success"); + setMessage(data.message); + await dispatch(getCurrentUser()); + setTimeout(() => navigate("/"), 3000); + } else { + setStatus("error"); + setMessage(data.message); + setIsExpired(data.expired || false); + } + } catch (error) { + setStatus("error"); + setMessage("Failed to verify email. Please try again."); + } + }; + + verifyEmail(); + }, [searchParams, navigate, dispatch]); + + const handleResendEmail = () => { + navigate("/resend-verification"); + }; + + return ( + + + + {status === "loading" && ( + <> + + + Verifying Your Email... + + + Please wait while we verify your email address. + + + )} + + {status === "success" && ( + <> + + + Email Verified! + + + {message} + + + You will be redirected to the home page in a few seconds... + + + + )} + + {status === "error" && ( + <> + + + Verification Failed + + + {message} + + {isExpired && ( + + Your verification link has expired. Please request a new one. + + )} + + {isExpired && ( + + )} + + + + )} + + + + ); +}; + +export default VerifyEmail; From 04fe6979bf1cff3699cbd83bd01701cba8a4c3ef Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 12 Dec 2025 14:13:20 -0500 Subject: [PATCH 17/46] feat: implement email verification workflow for user registration --- backend/services/email.service.js | 7 +- backend/src/controllers/auth.controller.js | 14 +- backend/src/routes/auth.routes.js | 2 +- src/components/Routes.tsx | 6 + src/components/User/UserLogin.tsx | 52 ++++++- src/components/User/UserSignup.tsx | 51 ++++++- src/pages/ResendVerification.tsx | 163 +++++++++++++++++++++ src/pages/VerifyEmail.tsx | 12 +- src/redux/auth/auth.action.ts | 9 +- src/redux/auth/auth.slice.ts | 68 +++++++-- src/redux/auth/types/auth.interface.ts | 26 +++- src/services/auth.service.ts | 20 ++- 12 files changed, 390 insertions(+), 40 deletions(-) create mode 100644 src/pages/ResendVerification.tsx diff --git a/backend/services/email.service.js b/backend/services/email.service.js index 7ca6bc1..06853f8 100644 --- a/backend/services/email.service.js +++ b/backend/services/email.service.js @@ -77,7 +77,12 @@ class EmailService { }; try { - await this.transporter.sendMail(mailOptions); + const info = await this.transporter.sendMail(mailOptions); + //Log preview URL in development + if (process.env.NODE_ENV !== "production") { + console.log("📧 Welcome email sent!"); + console.log("Preview URL:", nodemailer.getTestMessageUrl(info)); + } return { success: true }; } catch (error) { console.error("Welcome email error:", error); diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 8e55662..f6f041c 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -1,11 +1,8 @@ -const jwt = require("jsonwebtoken"); const { User } = require("../models"); const bcrypt = require("bcrypt"); const { setTokenCookie } = require("../middleware/auth.middleware"); const emailService = require("../../services/email.service"); -const JWT_SECRET = process.env.JWT_SECRET; - // register new user const register = async (req, res) => { try { @@ -81,12 +78,7 @@ const register = async (req, res) => { email_verified: isOAuthSignup ? true : false, // OAuth users auto-verified }); - // generate JWT token - // const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { - // expiresIn: "1h", - // }); - - // NEW: For traditional signup, send verification email + // For traditional signup, send verification email if (!isOAuthSignup) { const verificationToken = user.generateVerificationToken(); await user.save(); @@ -111,16 +103,16 @@ const register = async (req, res) => { }); } - //set suthentication cookie + // OAuth signup: set authentication cookie setTokenCookie(res, user); res.status(201).json({ message: "User registered successfully", - // token, user: { id: user.id, username: user.username, email: user.email, + email_verified: user.email_verified, }, }); } catch (error) { diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index 93f777e..3166fa2 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -27,7 +27,7 @@ router.post("/logout", requireAuth, logout); // email verification routes router.get("/verify-email", verifyEmail); -router.post("resend-verification", resendVerificationEmail); +router.post("/resend-verification", resendVerificationEmail); // Google OAuth routes router.get( diff --git a/src/components/Routes.tsx b/src/components/Routes.tsx index 54c3e24..3bc7468 100644 --- a/src/components/Routes.tsx +++ b/src/components/Routes.tsx @@ -5,9 +5,11 @@ import DatabasePage from "pages/DatabasePage"; import DatasetDetailPage from "pages/DatasetDetailPage"; import DatasetPage from "pages/DatasetPage"; import Home from "pages/Home"; +import ResendVerification from "pages/ResendVerification"; import SearchPage from "pages/SearchPage"; import UpdatedDatasetDetailPage from "pages/UpdatedDatasetDetailPage"; import NewDatasetPage from "pages/UpdatedDatasetPage"; +import VerifyEmail from "pages/VerifyEmail"; import React from "react"; import { Navigate, Route, Routes as RouterRoutes } from "react-router-dom"; import RoutesEnum from "types/routes.enum"; @@ -42,6 +44,10 @@ const Routes = () => ( {/* About Page */} } /> + + {/* Email Verification Routes */} + } /> + } /> diff --git a/src/components/User/UserLogin.tsx b/src/components/User/UserLogin.tsx index 269bc84..39be5db 100644 --- a/src/components/User/UserLogin.tsx +++ b/src/components/User/UserLogin.tsx @@ -16,9 +16,10 @@ import { Colors } from "design/theme"; import { useAppDispatch } from "hooks/useAppDispatch"; import { useAppSelector } from "hooks/useAppSelector"; import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; import { loginUser } from "redux/auth/auth.action"; import { AuthSelector } from "redux/auth/auth.selector"; -import { clearError } from "redux/auth/auth.slice"; +import { clearError, isLoginErrorResponse } from "redux/auth/auth.slice"; interface UserLoginProps { open: boolean; @@ -32,6 +33,7 @@ const UserLogin: React.FC = ({ onSwitchToSignup, }) => { const dispatch = useAppDispatch(); + const navigate = useNavigate(); // add const auth = useAppSelector(AuthSelector); const { loading, error: reduxError } = auth; @@ -39,6 +41,7 @@ const UserLogin: React.FC = ({ const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(""); + const [unverifiedEmail, setUnverifiedEmail] = useState(""); // add const handleOAuthLogin = (provider: "google" | "orcid") => { const apiUrl = process.env.REACT_APP_API_URL || "http://localhost:5000"; @@ -48,21 +51,43 @@ const UserLogin: React.FC = ({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); + setUnverifiedEmail(""); // add dispatch(clearError()); const result = await dispatch(loginUser({ email, password })); if (loginUser.fulfilled.match(result)) { // Success - close modal handleClose(); } else { - // Error - show in password field - setError(reduxError || "Login failed. Please try again."); + // ✅ NEW: Check if it's the unverified email error + const errorPayload = result.payload; + + if (isLoginErrorResponse(errorPayload)) { + // Email not verified error + setError(errorPayload.message); + setUnverifiedEmail(errorPayload.email); + } else { + // Other errors (wrong password, etc.) + setError( + typeof errorPayload === "string" + ? errorPayload + : "Login failed. Please try again." + ); + } + // setError(reduxError || "Login failed. Please try again."); } }; + // ✅ NEW: Handle resend verification + const handleResendVerification = () => { + handleClose(); + navigate(`/resend-verification?email=${unverifiedEmail}`); + }; + const handleClose = () => { setEmail(""); setPassword(""); setError(""); + setUnverifiedEmail(""); // add setShowPassword(false); dispatch(clearError()); onClose(); @@ -113,6 +138,27 @@ const UserLogin: React.FC = ({ {error && ( {error} + {/* ✅ NEW: Show "Resend Email" button only for unverified email error */} + {unverifiedEmail && ( + + + + )} )} = ({ const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [error, setError] = useState(""); + // add + const [success, setSuccess] = useState(false); + const [successMessage, setSuccessMessage] = useState(""); + const handleOAuthSignup = (provider: "google" | "orcid") => { const apiUrl = process.env.REACT_APP_API_URL || "http://localhost:5000"; window.location.href = `${apiUrl}/api/v1/auth/${provider}`; @@ -88,6 +92,7 @@ const UserSignup: React.FC = ({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); + setSuccess(false); dispatch(clearError()); if (!validateForm()) { @@ -102,9 +107,36 @@ const UserSignup: React.FC = ({ }) ); if (signupUser.fulfilled.match(result)) { - handleClose(); + // handleClose(); + if (result.payload.requiresVerification) { + // Traditional signup - show verification message + setSuccess(true); + setSuccessMessage( + result.payload.message || + "Registration successful! Please check your email to verify your account." + ); + + // Clear form + setFormData({ + username: "", + email: "", + password: "", + confirmPassword: "", + }); + + // Auto-close after 5 seconds + setTimeout(() => { + handleClose(); + }, 5000); + } else { + // OAuth signup - user is logged in, close immediately + handleClose(); + } } else { - setError(reduxError || "Signup failed. Please try again."); + // setError(reduxError || "Signup failed. Please try again."); + setError( + (result.payload as string) || "Signup failed. Please try again." + ); } }; @@ -116,6 +148,8 @@ const UserSignup: React.FC = ({ confirmPassword: "", }); setError(""); + setSuccess(false); // ← NEW + setSuccessMessage(""); // ← NEW setShowPassword(false); setShowConfirmPassword(false); dispatch(clearError()); @@ -164,6 +198,12 @@ const UserSignup: React.FC = ({ + {/* NEW: Success alert */} + {success && ( + + {successMessage} + + )} {error && ( {error} @@ -175,6 +215,7 @@ const UserSignup: React.FC = ({ value={formData.username} onChange={handleChange("username")} required + disabled={loading || success} // ← NEW: Disable if success sx={{ mb: 2, "& .MuiOutlinedInput-root": { @@ -197,6 +238,7 @@ const UserSignup: React.FC = ({ value={formData.email} onChange={handleChange("email")} required + disabled={loading || success} // ← NEW: Disable if success sx={{ mb: 2, "& .MuiOutlinedInput-root": { @@ -219,6 +261,7 @@ const UserSignup: React.FC = ({ value={formData.password} onChange={handleChange("password")} required + disabled={loading || success} // ← NEW: Disable if success InputProps={{ endAdornment: ( @@ -253,6 +296,7 @@ const UserSignup: React.FC = ({ value={formData.confirmPassword} onChange={handleChange("confirmPassword")} required + disabled={loading || success} // ← NEW: Disable if success InputProps={{ endAdornment: ( @@ -284,7 +328,8 @@ const UserSignup: React.FC = ({ type="submit" fullWidth variant="contained" - disabled={loading} + // disabled={loading} + disabled={loading || success} // ← NEW: Disable if success sx={{ backgroundColor: Colors.purple, color: Colors.white, diff --git a/src/pages/ResendVerification.tsx b/src/pages/ResendVerification.tsx new file mode 100644 index 0000000..709b809 --- /dev/null +++ b/src/pages/ResendVerification.tsx @@ -0,0 +1,163 @@ +import { Email } from "@mui/icons-material"; +import { + Box, + Container, + Typography, + TextField, + Button, + Alert, + Paper, +} from "@mui/material"; +import { Colors } from "design/theme"; +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +const ResendVerification: React.FC = () => { + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setSuccess(false); + setLoading(true); + + try { + const response = await fetch( + `${ + process.env.REACT_APP_API_URL || "http://localhost:5000" + }/api/v1/auth/resend-verification`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ email }), + } + ); + + const data = await response.json(); + if (response.ok) { + setSuccess(true); + setEmail(""); + } else { + setError(data.message || "Failed to send verification email"); + } + } catch (error) { + setError("Network error. Please try again."); + } finally { + setLoading(false); + } + }; + return ( + + + + + + + Resend Verification Email + + + Enter your email address and we'll send you a new verification + link. + + + + {success && ( + + Verification email sent! Please check your inbox and spam folder. + + )} + + {error && ( + + {error} + + )} + + + setEmail(e.target.value)} + required + disabled={loading} + sx={{ + mb: 3, + "& .MuiOutlinedInput-root": { + "& fieldset": { borderColor: Colors.primary.light }, + "&:hover fieldset": { borderColor: Colors.purple }, + "&.Mui-focused fieldset": { borderColor: Colors.purple }, + }, + "& .MuiInputLabel-root": { + "&.Mui-focused": { color: Colors.purple }, + }, + }} + /> + + + + + + + + + ); +}; + +export default ResendVerification; diff --git a/src/pages/VerifyEmail.tsx b/src/pages/VerifyEmail.tsx index f8c8eb2..a715a84 100644 --- a/src/pages/VerifyEmail.tsx +++ b/src/pages/VerifyEmail.tsx @@ -10,7 +10,7 @@ import { } from "@mui/material"; import { Colors } from "design/theme"; import { useAppDispatch } from "hooks/useAppDispatch"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { getCurrentUser } from "redux/auth/auth.action"; @@ -25,7 +25,14 @@ const VerifyEmail: React.FC = () => { const [message, setMessage] = useState(""); const [isExpired, setIsExpired] = useState(false); + //add + const hasVerified = useRef(false); + useEffect(() => { + // prevent duplicate verification attempts + if (hasVerified.current) { + return; + } const verifyEmail = async () => { const token = searchParams.get("token"); if (!token) { @@ -33,6 +40,9 @@ const VerifyEmail: React.FC = () => { setMessage("No verification token provided"); return; } + //mark as processing + hasVerified.current = true; + try { const response = await fetch( `${ diff --git a/src/redux/auth/auth.action.ts b/src/redux/auth/auth.action.ts index 9c8d2b7..fa2b3f9 100644 --- a/src/redux/auth/auth.action.ts +++ b/src/redux/auth/auth.action.ts @@ -7,8 +7,13 @@ export const loginUser = createAsyncThunk( async (credentials: LoginCredentials, { rejectWithValue }) => { try { const response = await AuthService.login(credentials); - return response.user; + // return response.user; + return response; } catch (error: any) { + // Check if error has LoginErrorResponse data + if (error.data && error.data.requiresVerification) { + return rejectWithValue(error.data); + } return rejectWithValue(error.message || "Login failed"); } } @@ -44,7 +49,7 @@ export const signupUser = createAsyncThunk( try { console.log("signupdata", signupData); const response = await AuthService.signup(signupData); - return response.user; + return response; } catch (error: any) { return rejectWithValue(error.message || "Signup failed"); } diff --git a/src/redux/auth/auth.slice.ts b/src/redux/auth/auth.slice.ts index 2bf3887..a43984a 100644 --- a/src/redux/auth/auth.slice.ts +++ b/src/redux/auth/auth.slice.ts @@ -4,9 +4,25 @@ import { logoutUser, signupUser, } from "./auth.action"; -import { IAuthState, User } from "./types/auth.interface"; +import { + IAuthState, + User, + LoginResponse, + SignupResponse, + LoginErrorResponse, +} from "./types/auth.interface"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +// ✅ ADD: Type guard function +export function isLoginErrorResponse(error: any): error is LoginErrorResponse { + return ( + typeof error === "object" && + error !== null && + "requiresVerification" in error && + "message" in error + ); +} + const initialState: IAuthState = { user: null, isLoggedIn: false, @@ -29,15 +45,28 @@ const authSlice = createSlice({ state.loading = true; state.error = null; }) - .addCase(loginUser.fulfilled, (state, action: PayloadAction) => { - state.loading = false; - state.isLoggedIn = true; - state.user = action.payload; - state.error = null; - }) + .addCase( + loginUser.fulfilled, + (state, action: PayloadAction) => { + state.loading = false; + state.isLoggedIn = true; + state.user = action.payload.user; + state.error = null; + } + ) .addCase(loginUser.rejected, (state, action) => { state.loading = false; - state.error = action.payload as string; + // state.error = action.payload as string; + const errorPayload = action.payload; + + // Check if it's LoginErrorResponse (email not verified) + if (isLoginErrorResponse(errorPayload)) { + // TypeScript now knows errorPayload is LoginErrorResponse + state.error = errorPayload.message; + // state.unverifiedEmail = errorPayload.email; // if you add this to state + } else { + state.error = errorPayload as string; + } }) // Get Current User .addCase(getCurrentUser.pending, (state) => { @@ -79,12 +108,23 @@ const authSlice = createSlice({ state.loading = true; state.error = null; }) - .addCase(signupUser.fulfilled, (state, action: PayloadAction) => { - state.loading = false; - state.isLoggedIn = true; - state.user = action.payload; - state.error = null; - }) + .addCase( + signupUser.fulfilled, + (state, action: PayloadAction) => { + state.loading = false; + + // state.isLoggedIn = true; + // state.user = action.payload; + if (action.payload.requiresVerification) { + state.isLoggedIn = false; + state.user = null; + } else { + state.isLoggedIn = true; + state.user = action.payload.user; + } + state.error = null; + } + ) .addCase(signupUser.rejected, (state, action) => { state.loading = false; state.error = action.payload as string; diff --git a/src/redux/auth/types/auth.interface.ts b/src/redux/auth/types/auth.interface.ts index 1065ccf..419e799 100644 --- a/src/redux/auth/types/auth.interface.ts +++ b/src/redux/auth/types/auth.interface.ts @@ -2,6 +2,12 @@ export interface User { id: number; username: string; email: string; + email_verified: boolean; + created_at?: string; // (optional) + updated_at?: string; // (optional) + google_id?: string; // (optional, for OAuth) + orcid_id?: string; // (optional, for OAuth) + github_id?: string; // (optional, for OAuth) } export interface LoginCredentials { @@ -15,11 +21,29 @@ export interface SignupData { password: string; } -export interface AuthResponse { +// export interface AuthResponse { +// message: string; +// user: User; +// requiresVerification?: boolean; +// } + +export interface SignupResponse { + message: string; + user: User; + requiresVerification?: boolean; +} + +export interface LoginResponse { message: string; user: User; } +export interface LoginErrorResponse { + message: string; + requiresVerification: boolean; + email: string; +} + export interface IAuthState { user: User | null; isLoggedIn: boolean; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index dbe820d..10582a2 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,5 +1,8 @@ import { - AuthResponse, + // AuthResponse, + SignupResponse, // ← Changed from AuthResponse + LoginResponse, // ← Added + LoginErrorResponse, // ← Added LoginCredentials, SignupData, User, @@ -8,7 +11,7 @@ import { const API_URL = process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1"; export const AuthService = { - login: async (credentials: LoginCredentials): Promise => { + login: async (credentials: LoginCredentials): Promise => { const response = await fetch(`${API_URL}/auth/login`, { method: "POST", headers: { @@ -19,6 +22,17 @@ export const AuthService = { }); const data = await response.json(); if (!response.ok) { + // NEW: Check if it's the unverified email error (403) + if (response.status === 403 && data.requiresVerification) { + // Create a typed error that includes the full error response + const error = new Error( + data.message || "Email not verified" + ) as Error & { + data: LoginErrorResponse; + }; + error.data = data as LoginErrorResponse; + throw error; + } throw new Error(data.message || "Login failed"); } return data; @@ -49,7 +63,7 @@ export const AuthService = { throw new Error("Logout failed"); } }, - signup: async (signupData: SignupData): Promise => { + signup: async (signupData: SignupData): Promise => { const response = await fetch(`${API_URL}/auth/register`, { method: "POST", headers: { From ffa81665d9ea1bb00f6b65a8436733000df3192a Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 15 Dec 2025 15:31:16 -0500 Subject: [PATCH 18/46] feat: modify welcome email template --- backend/services/email.service.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/services/email.service.js b/backend/services/email.service.js index 06853f8..5facd13 100644 --- a/backend/services/email.service.js +++ b/backend/services/email.service.js @@ -157,10 +157,12 @@ class EmailService {

Your email has been verified successfully! Welcome to the NeuroJSON.io community.

You can now:

    -
  • Browse and search neuroscience datasets
  • -
  • Upload and share your own data
  • -
  • Save and like datasets
  • -
  • Comment and collaborate
  • +
  • Browse and search neuroimaging datasets
  • +
  • Preview data in-browser without downloading
  • +
  • Download datasets via UI or REST API
  • +
  • Save and like your favorite datasets
  • +
  • Comment on datasets and share feedback
  • +
  • Share search results or datasets with shareable links
new Date() + ); + } + + // Clear reset token + clearResetPasswordToken() { + this.reset_password_token = null; + this.reset_password_expires = null; + } + + // Get full name + getFullName() { + return `${this.first_name} ${this.last_name}`; + } } User.init( @@ -103,6 +137,32 @@ User.init( type: DataTypes.DATE, allowNull: true, }, + // NEW FIELDS FOR PASSWORD RESET + reset_password_token: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + reset_password_expires: { + type: DataTypes.DATE, + allowNull: true, + }, + first_name: { + type: DataTypes.STRING(255), + allowNull: false, + }, + last_name: { + type: DataTypes.STRING(255), + allowNull: false, + }, + company: { + type: DataTypes.STRING(255), + allowNull: false, + }, + interests: { + type: DataTypes.TEXT, + allowNull: true, + }, created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW, From 0a5d0ec9b8fc8c254f667ec070e398960f1e2bc2 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 17 Dec 2025 15:29:05 -0500 Subject: [PATCH 20/46] feat: updata sign up and add completeProfile controller --- backend/src/controllers/auth.controller.js | 127 +++++++++++++++++++-- 1 file changed, 119 insertions(+), 8 deletions(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index f6f041c..cc8cdac 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -6,8 +6,18 @@ const emailService = require("../../services/email.service"); // register new user const register = async (req, res) => { try { - const { username, email, password, orcid_id, google_id, github_id } = - req.body; + const { + username, + email, + password, + orcid_id, + google_id, + github_id, + firstName, + lastName, + company, + interests, + } = req.body; // check if OAuth or traditional signup const isOAuthSignup = orcid_id || google_id || github_id; @@ -19,6 +29,13 @@ const register = async (req, res) => { }); } + // NEW: Validate profile fields for traditional signup + if (!isOAuthSignup && (!firstName || !lastName || !company)) { + return res.status(400).json({ + message: "First name, last name, and company/institution are required", + }); + } + // traditional signup: password is required if (!isOAuthSignup && !password) { return res.status(400).json({ @@ -76,6 +93,10 @@ const register = async (req, res) => { google_id: google_id || null, github_id: github_id || null, email_verified: isOAuthSignup ? true : false, // OAuth users auto-verified + first_name: firstName || "", // NEW + last_name: lastName || "", // NEW + company: company || "", // NEW + interests: interests || null, // NEW }); // For traditional signup, send verification email @@ -98,6 +119,10 @@ const register = async (req, res) => { username: user.username, email: user.email, email_verified: user.email_verified, + firstName: user.first_name, // NEW + lastName: user.last_name, // NEW + company: user.company, // NEW + interests: user.interests, // NEW }, requiresVerification: true, }); @@ -113,6 +138,10 @@ const register = async (req, res) => { username: user.username, email: user.email, email_verified: user.email_verified, + firstName: user.first_name, // NEW + lastName: user.last_name, // NEW + company: user.company, // NEW + interests: user.interests, // NEW }, }); } catch (error) { @@ -178,10 +207,6 @@ const login = async (req, res) => { return res.status(401).json({ message: "Invalid credentials" }); } - // generate JWT token - // const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { - // expiresIn: "1h", - // }); // NEW: Check if email is verified if (!user.email_verified) { return res.status(403).json({ @@ -228,7 +253,11 @@ const getCurrentUser = async (req, res) => { id: user.id, username: user.username, email: user.email, - email_verified: user.email_verified, // NEW + email_verified: user.email_verified, + firstName: user.first_name, // NEW + lastName: user.last_name, // NEW + company: user.company, // NEW + interests: user.interests, // NEW created_at: user.created_at, updated_at: user.updated_at, }, @@ -330,10 +359,92 @@ const resendVerificationEmail = async (req, res) => { } }; +// NEW: Complete profile for OAuth users +const completeProfile = async (req, res) => { + try { + const { token, firstName, lastName, company, interests } = req.body; + + // Validate token + const jwt = require("jsonwebtoken"); + const JWT_SECRET = process.env.JWT_SECRET; + + let decoded; + try { + decoded = jwt.verify(token, JWT_SECRET); + if (decoded.purpose !== "profile_completion") { + return res.status(401).json({ message: "Invalid token" }); + } + } catch (error) { + return res.status(401).json({ + message: "Token expired or invalid. Please sign in again.", + }); + } + + // Validate input + if (!firstName || !lastName || !company) { + return res.status(400).json({ + message: "First name, last name, and company/institution are required", + }); + } + + // Validate field lengths + if (firstName.trim().length < 1 || firstName.trim().length > 255) { + return res + .status(400) + .json({ message: "First name must be between 1 and 255 characters" }); + } + if (lastName.trim().length < 1 || lastName.trim().length > 255) { + return res + .status(400) + .json({ message: "Last name must be between 1 and 255 characters" }); + } + if (company.trim().length < 1 || company.trim().length > 255) { + return res + .status(400) + .json({ + message: "Company/institution must be between 1 and 255 characters", + }); + } + + // Find and update user + const user = await User.findByPk(decoded.userId); + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + // Update profile + user.first_name = firstName.trim(); + user.last_name = lastName.trim(); + user.company = company.trim(); + user.interests = interests ? interests.trim() : null; + await user.save(); + + // Now set the actual login cookie + setTokenCookie(res, user); + + res.json({ + message: "Profile completed successfully", + user: { + id: user.id, + email: user.email, + username: user.username, + firstName: user.first_name, + lastName: user.last_name, + company: user.company, + interests: user.interests, + }, + }); + } catch (error) { + console.error("Complete profile error:", error); + res.status(500).json({ message: "Server error" }); + } +}; + module.exports = { register, login, getCurrentUser, logout, - resendVerificationEmail, // NEW + resendVerificationEmail, + completeProfile, // New }; From ab427b91ed6996b1fb6c037f24892dd7b7309a21 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 17 Dec 2025 16:30:06 -0500 Subject: [PATCH 21/46] feat: add change password controller --- backend/src/controllers/auth.controller.js | 61 ++++++++++++++++++++-- backend/src/routes/auth.routes.js | 2 + 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index cc8cdac..d2cfff9 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -399,11 +399,9 @@ const completeProfile = async (req, res) => { .json({ message: "Last name must be between 1 and 255 characters" }); } if (company.trim().length < 1 || company.trim().length > 255) { - return res - .status(400) - .json({ - message: "Company/institution must be between 1 and 255 characters", - }); + return res.status(400).json({ + message: "Company/institution must be between 1 and 255 characters", + }); } // Find and update user @@ -440,6 +438,58 @@ const completeProfile = async (req, res) => { } }; +const changePassword = async (req, res) => { + try { + const { currentPassword, newPassword } = req.body; + const user = req.user; + + // Validation + if (!currentPassword || !newPassword) { + return res.status(400).json({ + message: "Current password and new password are required", + }); + } + + if (newPassword.length < 8) { + return res.status(400).json({ + message: "New password must be at least 8 characters long", + }); + } + + // Check if user has a password (OAuth users don't) + if (!user.hashed_password) { + return res.status(400).json({ + message: + "Cannot change password for OAuth accounts. Please use your OAuth provider.", + }); + } + + // Verify current password + const isValid = await user.comparePassword(currentPassword); + if (!isValid) { + return res.status(401).json({ message: "Current password is incorrect" }); + } + + // Check if new password is same as current + const isSameAsOld = await user.comparePassword(newPassword); + if (isSameAsOld) { + return res.status(400).json({ + message: "New password must be different from current password", + }); + } + + // Hash and save new password + const hashedPassword = await bcrypt.hash(newPassword, 10); + user.hashed_password = hashedPassword; + await user.save(); + + res.json({ message: "Password changed successfully" }); + } catch (error) { + console.error("Password change error:", error); + res.status(500).json({ message: "Server error" }); + } +}; + module.exports = { register, login, @@ -447,4 +497,5 @@ module.exports = { logout, resendVerificationEmail, completeProfile, // New + changePassword, // New }; diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index 3166fa2..38caff6 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -7,6 +7,8 @@ const { getCurrentUser, logout, resendVerificationEmail, + completeProfile, + changePassword, } = require("../controllers/auth.controller"); const { verifyEmail } = require("../controllers/verification.controller"); const { From e8e0420e2f88e3346661fbe19e0369bbe274d7a8 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Thu, 18 Dec 2025 11:26:06 -0500 Subject: [PATCH 22/46] feat: add forgotPassword controller --- backend/src/controllers/auth.controller.js | 38 ++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index d2cfff9..a1f343d 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -490,6 +490,43 @@ const changePassword = async (req, res) => { } }; +const forgotPassword = async (req, res) => { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ message: "Email is required" }); + } + + const user = await User.findOne({ where: { email } }); + + const successMessage = + "If an account with that email exists, a password reset link has been sent."; + + if (!user || !user.hashed_password) { + return res.json({ message: successMessage }); + } + + const resetToken = user.generateResetPasswordToken(); + await user.save(); + + const resetUrl = `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/reset-password?token=${resetToken}`; + + await emailService.sendPasswordResetEmail( + user.email, + resetUrl, + user.first_name + ); + + res.json({ message: successMessage }); + } catch (error) { + console.error("Forgot password error:", error); + res.status(500).json({ message: "Server error" }); + } +}; + module.exports = { register, login, @@ -498,4 +535,5 @@ module.exports = { resendVerificationEmail, completeProfile, // New changePassword, // New + forgotPassword, // New }; From cbf3f2bbf6efe675c0da454307c8395a647662e0 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Thu, 18 Dec 2025 16:34:27 -0500 Subject: [PATCH 23/46] feat: add resetPassword controller --- backend/src/controllers/auth.controller.js | 54 ++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index a1f343d..13cd313 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -1,5 +1,6 @@ const { User } = require("../models"); const bcrypt = require("bcrypt"); +const crypto = require("crypto"); const { setTokenCookie } = require("../middleware/auth.middleware"); const emailService = require("../../services/email.service"); @@ -527,6 +528,58 @@ const forgotPassword = async (req, res) => { } }; +const resetPassword = async (req, res) => { + try { + const { token } = req.query; + const { password } = req.body; + + // Validation + if (!token) { + return res.status(400).json({ message: "Token is required" }); + } + + if (!password) { + return res.status(400).json({ message: "Password is required" }); + } + + if (password.length < 8) { + return res.status(400).json({ + message: "Password must be at least 8 characters long", + }); + } + + // Hash the token to match what's stored in database + const hashedToken = crypto.createHash("sha256").update(token).digest("hex"); + + // Find user with this hashed token and non-expired timestamp + const user = await User.findOne({ + where: { + reset_password_token: hashedToken, + reset_password_expires: { [Op.gt]: new Date() }, + }, + }); + + if (!user) { + return res.status(400).json({ + message: "Invalid or expired password reset token", + }); + } + + // Update password + const hashedPassword = await bcrypt.hash(password, 10); + user.hashed_password = hashedPassword; + user.clearResetPasswordToken(); + await user.save(); + + res.json({ + message: "Password has been reset successfully. You can now log in.", + }); + } catch (error) { + console.error("Reset password error:", error); + res.status(500).json({ message: "Server error" }); + } +}; + module.exports = { register, login, @@ -536,4 +589,5 @@ module.exports = { completeProfile, // New changePassword, // New forgotPassword, // New + resetPassword, // New }; From b3948c23ead51b676e8d40f244fdb0dde724265d Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Thu, 18 Dec 2025 16:51:56 -0500 Subject: [PATCH 24/46] feat: add reset passoword email template to email service; add backend routes --- backend/services/email.service.js | 71 +++++++++++++++++++++++++++++++ backend/src/routes/auth.routes.js | 10 +++++ 2 files changed, 81 insertions(+) diff --git a/backend/services/email.service.js b/backend/services/email.service.js index 5facd13..92c9f63 100644 --- a/backend/services/email.service.js +++ b/backend/services/email.service.js @@ -90,6 +90,32 @@ class EmailService { } } + async sendPasswordResetEmail(email, resetUrl, firstName) { + const mailOptions = { + from: `"NeuroJSON.io" <${ + process.env.SMTP_FROM || "noreply@neurojson.io" + }>`, + to: email, + subject: "Password Reset Request - NeuroJSON.io", + html: this.getPasswordResetTemplate(firstName, resetUrl), + text: `Hi ${firstName},\n\nYou requested to reset your password for your NeuroJSON.io account.\n\nClick this link to reset your password:\n${resetUrl}\n\nThis link will expire in 1 hour.\n\nIf you didn't request this, please ignore this email.\n\nBest regards,\nThe NeuroJSON.io Team`, + }; + + try { + const info = await this.transporter.sendMail(mailOptions); + + if (process.env.NODE_ENV !== "production") { + console.log("📧 Password reset email sent!"); + console.log("Preview URL:", nodemailer.getTestMessageUrl(info)); + } + + return { success: true, messageId: info.messageId }; + } catch (error) { + console.error("Password reset email error:", error); + throw new Error("Failed to send password reset email"); + } + } + getVerificationEmailTemplate(username, verificationUrl) { return ` @@ -179,6 +205,51 @@ class EmailService { `; } + + getPasswordResetTemplate(firstName, resetUrl) { + return ` + + + + + + + +
+
+

🔐 Password Reset

+

NeuroJSON.io

+
+
+

Hi ${firstName},

+

You requested to reset your password for your NeuroJSON.io account.

+

Click the button below to reset your password:

+
+

Or copy and paste this link into your browser:

+

${resetUrl}

+
+ ⏱️ This link will expire in 1 hour for security reasons. +
+

If you didn't request this password reset, please ignore this email. Your password will remain unchanged.

+
+ +
+ + + `; + } } module.exports = new EmailService(); diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index 38caff6..ebbb82d 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -9,6 +9,8 @@ const { resendVerificationEmail, completeProfile, changePassword, + forgotPassword, + resetPassword, } = require("../controllers/auth.controller"); const { verifyEmail } = require("../controllers/verification.controller"); const { @@ -31,6 +33,14 @@ router.post("/logout", requireAuth, logout); router.get("/verify-email", verifyEmail); router.post("/resend-verification", resendVerificationEmail); +// NEW: Password management routes +router.post("/change-password", requireAuth, changePassword); +router.post("/forgot-password", forgotPassword); +router.post("/reset-password", resetPassword); + +// NEW: OAuth profile completion route +router.post("/complete-profile", completeProfile); + // Google OAuth routes router.get( "/google", From 55f2c3262dccd1927514fc6d6963ce416ac4789c Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Thu, 18 Dec 2025 17:11:29 -0500 Subject: [PATCH 25/46] feat: add needs profile completion method in user model --- backend/src/models/User.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/models/User.js b/backend/src/models/User.js index fe95177..ba87089 100644 --- a/backend/src/models/User.js +++ b/backend/src/models/User.js @@ -78,6 +78,11 @@ class User extends Model { this.reset_password_expires = null; } + // NEW: Check if profile needs completion + needsProfileCompletion() { + return !this.first_name || !this.last_name || !this.company; + } + // Get full name getFullName() { return `${this.first_name} ${this.last_name}`; From 7631c310c13563b7c15de1238e664a10c35c720a Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 22 Dec 2025 13:15:44 -0500 Subject: [PATCH 26/46] feat: modify google OAuth strategy and callback controller --- backend/config/passport.config.js | 11 +++++++++ backend/src/controllers/oauth.controller.js | 26 +++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/backend/config/passport.config.js b/backend/config/passport.config.js index 2ff85e2..043ba56 100644 --- a/backend/config/passport.config.js +++ b/backend/config/passport.config.js @@ -22,6 +22,10 @@ passport.use( const googleId = profile.id; const username = profile.displayName || email.split("@")[0]; + // NEW: Extract name from Google profile + const firstName = profile.name?.givenName || ""; + const lastName = profile.name?.familyName || ""; + // Check if user already exists with this Google ID let user = await User.findOne({ where: { google_id: googleId }, @@ -40,6 +44,9 @@ passport.use( if (user) { // User exists with email but no Google ID - link the accounts user.google_id = googleId; + // NEW: Update profile fields if they were empty + if (!user.first_name && firstName) user.first_name = firstName; + if (!user.last_name && lastName) user.last_name = lastName; await user.save(); return done(null, user); } @@ -51,6 +58,10 @@ passport.use( google_id: googleId, hashed_password: null, // OAuth users don't have passwords email_verified: true, + // Set from Google profile, or empty string if not available + first_name: firstName || "", + last_name: lastName || "", + company: "", // Always empty for new OAuth users - must be completed }); return done(null, user); diff --git a/backend/src/controllers/oauth.controller.js b/backend/src/controllers/oauth.controller.js index 29320f0..1826e77 100644 --- a/backend/src/controllers/oauth.controller.js +++ b/backend/src/controllers/oauth.controller.js @@ -1,4 +1,6 @@ const { setTokenCookie } = require("../middleware/auth.middleware"); +const jwt = require("jsonwebtoken"); // new +const JWT_SECRET = process.env.JWT_SECRET; // new // google oauth initiate const googleAuth = (req, res, next) => { @@ -19,6 +21,30 @@ const googleCallback = (req, res) => { ); } + // NEW: Check if profile needs completion + if (user.needsProfileCompletion()) { + // Create temporary token for profile completion + const tempToken = jwt.sign( + { + userId: user.id, + purpose: "profile_completion", + email: user.email, + username: user.username, + firstName: user.first_name || "", + lastName: user.last_name || "", + }, + JWT_SECRET, + { expiresIn: "1h" } // 1 hour to complete profile + ); + + // Redirect to profile completion page + return res.redirect( + `${ + process.env.FRONTEND_URL || "http://localhost:3000" + }/complete-profile?token=${tempToken}` + ); + } + // set authentication cookie setTokenCookie(res, user); From e0b7a4c44b90d25bdf9e9bdbd9a86b42a57ba91b Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 22 Dec 2025 13:23:08 -0500 Subject: [PATCH 27/46] feat: add complete profile component --- src/components/User/CompleteProfile.tsx | 183 ++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/components/User/CompleteProfile.tsx diff --git a/src/components/User/CompleteProfile.tsx b/src/components/User/CompleteProfile.tsx new file mode 100644 index 0000000..3659a8d --- /dev/null +++ b/src/components/User/CompleteProfile.tsx @@ -0,0 +1,183 @@ +import { + Container, + Paper, + TextField, + Button, + Typography, + Box, + Alert, + CircularProgress, +} from "@mui/material"; +import axios from "axios"; +import React, { useState, useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; + +const CompleteProfile: React.FC = () => { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const token = searchParams.get("token"); + + const [formData, setFormData] = useState({ + firstName: "", + lastName: "", + company: "", + interests: "", + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (!token) { + navigate("/"); + return; + } + + // Try to decode token to pre-fill form (optional) + try { + const payload = JSON.parse(atob(token.split(".")[1])); + setFormData((prev) => ({ + ...prev, + firstName: payload.firstName || "", + lastName: payload.lastName || "", + })); + } catch (e) { + // Token decode failed, just continue with empty form + } + }, [token, navigate]); + + const handleChange = (e: React.ChangeEvent) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value, + }); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + await axios.post("/api/v1/auth/complete-profile", { + token, + ...formData, + }); + + // Profile completed successfully, redirect to home + navigate("/?auth=success"); + window.location.reload(); // Reload to update auth state + } catch (err: unknown) { + // setError(err.response?.data?.message || 'Failed to complete profile'); + if (axios.isAxiosError(err)) { + setError(err.response?.data?.message || "Failed to complete profile"); + } else { + setError("Failed to complete profile"); + } + setLoading(false); + } + }; + + return ( + + + + Complete Your Profile + + + Just a few more details to get you started on NeuroJSON.io + + + {error && ( + + {error} + + )} + + + + + + + + + + + + ⏱️ Take your time - this session is valid for 1 hour + + + + + + + ); +}; + +export default CompleteProfile; From a0d63e8f9ded22d3fc431f03eeb9a49cb9d7ca74 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 22 Dec 2025 13:25:33 -0500 Subject: [PATCH 28/46] feat: add complete profile route for OAuth users --- src/components/Routes.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/Routes.tsx b/src/components/Routes.tsx index 3bc7468..d7d789a 100644 --- a/src/components/Routes.tsx +++ b/src/components/Routes.tsx @@ -1,4 +1,5 @@ import ScrollToTop from "./ScrollToTop"; +import CompleteProfile from "./User/CompleteProfile"; import FullScreen from "design/Layouts/FullScreen"; import AboutPage from "pages/AboutPage"; import DatabasePage from "pages/DatabasePage"; @@ -48,6 +49,8 @@ const Routes = () => ( {/* Email Verification Routes */} } /> } /> + + } /> From 45019fc179c0f0911ff11c4ef533eaba2ad049c7 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Sat, 3 Jan 2026 14:18:47 -0500 Subject: [PATCH 29/46] modified backend url set up in complete profile component --- .env.development | 1 + .github/workflows/build-deploy-zodiac.yml | 5 +- .gitignore | 6 + backend/src/server.js | 11 +- src/components/User/CompleteProfile.tsx | 252 +++++++++++++++------- src/index.tsx | 35 ++- 6 files changed, 219 insertions(+), 91 deletions(-) create mode 100644 .env.development diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..3e730b7 --- /dev/null +++ b/.env.development @@ -0,0 +1 @@ +REACT_APP_API_URL = http://localhost:5000/api/v1 \ No newline at end of file diff --git a/.github/workflows/build-deploy-zodiac.yml b/.github/workflows/build-deploy-zodiac.yml index ca0bcca..c9f2a4d 100644 --- a/.github/workflows/build-deploy-zodiac.yml +++ b/.github/workflows/build-deploy-zodiac.yml @@ -33,7 +33,10 @@ jobs: - name: Build React App with Dynamic PUBLIC_URL run: | echo "Building for /dev/${{ env.BRANCH_NAME }}/" - PUBLIC_URL="/dev/${{ env.BRANCH_NAME }}/" yarn build + # PUBLIC_URL="/dev/${{ env.BRANCH_NAME }}/" yarn build + export PUBLIC_URL="/dev/${{ env.BRANCH_NAME }}/" + export REACT_APP_API_URL="/dev/${{ env.BRANCH_NAME }}" + yarn build - name: Copy JS libraries (jdata, bjdata etc.) run: | diff --git a/.gitignore b/.gitignore index 6c62e3d..cbfcb5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .env +.env.local +.env.*.local +.env.production node_modules .DS_Store package-lock.json @@ -8,5 +11,8 @@ build #backend backend/node_modules/ backend/.env +backend/.env.local +backend/.env.*.local backend/*.sqlite +backend/*.db !backend/package-lock.json \ No newline at end of file diff --git a/backend/src/server.js b/backend/src/server.js index bd80192..47e87bf 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -16,12 +16,21 @@ const app = express(); const PORT = process.env.PORT || 5000; // Middleware +// app.use( +// cors({ +// origin: process.env.CORS_ORIGIN || "http://localhost:3000", +// credentials: true, +// }) +// ); +const isProd = process.env.NODE_ENV === "production"; + app.use( cors({ - origin: process.env.CORS_ORIGIN || "http://localhost:3000", + origin: isProd ? true : process.env.CORS_ORIGIN || "http://localhost:3000", credentials: true, }) ); + app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); // parse cookies diff --git a/src/components/User/CompleteProfile.tsx b/src/components/User/CompleteProfile.tsx index 3659a8d..7541bd3 100644 --- a/src/components/User/CompleteProfile.tsx +++ b/src/components/User/CompleteProfile.tsx @@ -9,6 +9,7 @@ import { CircularProgress, } from "@mui/material"; import axios from "axios"; +import { Colors } from "design/theme"; import React, { useState, useEffect } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; @@ -25,6 +26,7 @@ const CompleteProfile: React.FC = () => { }); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [tokenExpired, setTokenExpired] = useState(false); useEffect(() => { if (!token) { @@ -55,13 +57,19 @@ const CompleteProfile: React.FC = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); + setTokenExpired(false); setLoading(true); try { - await axios.post("/api/v1/auth/complete-profile", { - token, - ...formData, - }); + await axios.post( + `${ + process.env.REACT_APP_API_URL || "http://localhost:5000" + }/api/v1/auth/complete-profile`, + { + token, + ...formData, + } + ); // Profile completed successfully, redirect to home navigate("/?auth=success"); @@ -69,7 +77,16 @@ const CompleteProfile: React.FC = () => { } catch (err: unknown) { // setError(err.response?.data?.message || 'Failed to complete profile'); if (axios.isAxiosError(err)) { - setError(err.response?.data?.message || "Failed to complete profile"); + const errorMessage = + err.response?.data?.message || "Failed to complete profile"; + setError(errorMessage); + // ← NEW: Check if token expired + if ( + errorMessage.toLowerCase().includes("expired") || + errorMessage.toLowerCase().includes("invalid") + ) { + setTokenExpired(true); + } } else { setError("Failed to complete profile"); } @@ -98,83 +115,154 @@ const CompleteProfile: React.FC = () => { )} - - - - - - - - - - - ⏱️ Take your time - this session is valid for 1 hour - - - - + {tokenExpired ? ( + + + Your session has expired. Please return to the home page and sign + in again to continue. + + + + ) : ( + + + + + + + + + + + ⏱️ Take your time - this session is valid for 1 hour + + + + + )} ); diff --git a/src/index.tsx b/src/index.tsx index ee78b23..aee13fe 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,15 +1,36 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; import App from "./App"; -import { Provider } from "react-redux"; -import store from "./redux/store"; -import { ThemeProvider } from "@mui/material/styles"; -import { CssBaseline } from "@mui/material"; import theme from "./design/theme"; +import store from "./redux/store"; +// add import * as preview from "./utils/preview.js"; +import { + previewdataurl, + previewdata, + dopreview, + drawpreview, + update, + initcanvas, + createStats, + setControlAngles, + setcrosssectionsizes, +} from "./utils/preview.js"; +import { CssBaseline } from "@mui/material"; +import { ThemeProvider } from "@mui/material/styles"; +// import axios from "axios"; +import React from "react"; +import ReactDOM from "react-dom/client"; +import { Provider } from "react-redux"; -import { previewdataurl, previewdata, dopreview, drawpreview, update, initcanvas, createStats, setControlAngles, setcrosssectionsizes } from "./utils/preview.js"; +// ----- new ------ +// Configure axios base URL from environment variable +// axios.defaults.baseURL = +// process.env.REACT_APP_API_URL || "http://localhost:5000"; +// axios.defaults.withCredentials = true; // Important for cookies! +// Log for debugging (optional) +// console.log("🚀 Environment:", process.env.NODE_ENV); +// console.log("🚀 API URL:", process.env.REACT_APP_API_URL); +// ------ end------ // Get the root element const rootElement = document.getElementById("root") as HTMLElement; From 36ee9fc08e73022ce03d6afd1abc25da2885a209 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 5 Jan 2026 16:54:16 -0500 Subject: [PATCH 30/46] fix: correct redirect path after OAuth profile completion; closes #111 --- src/components/User/CompleteProfile.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/User/CompleteProfile.tsx b/src/components/User/CompleteProfile.tsx index 7541bd3..79668b8 100644 --- a/src/components/User/CompleteProfile.tsx +++ b/src/components/User/CompleteProfile.tsx @@ -72,8 +72,11 @@ const CompleteProfile: React.FC = () => { ); // Profile completed successfully, redirect to home - navigate("/?auth=success"); - window.location.reload(); // Reload to update auth state + // navigate("/?auth=success"); + // window.location.reload(); // Reload to update auth state + // Use PUBLIC_URL to get the correct base path + const baseUrl = process.env.PUBLIC_URL || ""; + window.location.href = `${baseUrl}/?auth=success`; } catch (err: unknown) { // setError(err.response?.data?.message || 'Failed to complete profile'); if (axios.isAxiosError(err)) { From 40a5ba0b35899a08cc3704b380f1d44280a45070 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 5 Jan 2026 17:43:53 -0500 Subject: [PATCH 31/46] fix the double path redirect issue after fill out the form --- src/App.tsx | 3 ++- src/components/User/CompleteProfile.tsx | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d2a2469..28b62f3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -76,7 +76,8 @@ function AuthHandler() { // Clean up URL - this removes it from history // window.history.replaceState({}, "", window.location.pathname); - navigate(window.location.pathname, { replace: true }); + // navigate(window.location.pathname, { replace: true }); + navigate(location.pathname, { replace: true }); } }, [location.search, dispatch, navigate]); diff --git a/src/components/User/CompleteProfile.tsx b/src/components/User/CompleteProfile.tsx index 79668b8..3b1ce56 100644 --- a/src/components/User/CompleteProfile.tsx +++ b/src/components/User/CompleteProfile.tsx @@ -72,11 +72,11 @@ const CompleteProfile: React.FC = () => { ); // Profile completed successfully, redirect to home - // navigate("/?auth=success"); + navigate("/?auth=success", { replace: true }); // window.location.reload(); // Reload to update auth state // Use PUBLIC_URL to get the correct base path - const baseUrl = process.env.PUBLIC_URL || ""; - window.location.href = `${baseUrl}/?auth=success`; + // const baseUrl = process.env.PUBLIC_URL || ""; + // window.location.href = `${baseUrl}/?auth=success`; } catch (err: unknown) { // setError(err.response?.data?.message || 'Failed to complete profile'); if (axios.isAxiosError(err)) { From 5450a88bb6375c7e4e6ea073b7b464905d1a0db3 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Mon, 5 Jan 2026 18:28:54 -0500 Subject: [PATCH 32/46] update the value of REACT_APP_API_URL in yml file --- .github/workflows/build-deploy-zodiac.yml | 2 +- src/components/User/CompleteProfile.tsx | 4 ++-- src/components/User/UserLogin.tsx | 5 +++-- src/components/User/UserSignup.tsx | 5 +++-- src/pages/ResendVerification.tsx | 4 ++-- src/pages/VerifyEmail.tsx | 4 ++-- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-deploy-zodiac.yml b/.github/workflows/build-deploy-zodiac.yml index c9f2a4d..197421c 100644 --- a/.github/workflows/build-deploy-zodiac.yml +++ b/.github/workflows/build-deploy-zodiac.yml @@ -35,7 +35,7 @@ jobs: echo "Building for /dev/${{ env.BRANCH_NAME }}/" # PUBLIC_URL="/dev/${{ env.BRANCH_NAME }}/" yarn build export PUBLIC_URL="/dev/${{ env.BRANCH_NAME }}/" - export REACT_APP_API_URL="/dev/${{ env.BRANCH_NAME }}" + export REACT_APP_API_URL="/dev/${{ env.BRANCH_NAME }}/api/v1" yarn build - name: Copy JS libraries (jdata, bjdata etc.) diff --git a/src/components/User/CompleteProfile.tsx b/src/components/User/CompleteProfile.tsx index 3b1ce56..537b2f2 100644 --- a/src/components/User/CompleteProfile.tsx +++ b/src/components/User/CompleteProfile.tsx @@ -63,8 +63,8 @@ const CompleteProfile: React.FC = () => { try { await axios.post( `${ - process.env.REACT_APP_API_URL || "http://localhost:5000" - }/api/v1/auth/complete-profile`, + process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1" + }/auth/complete-profile`, { token, ...formData, diff --git a/src/components/User/UserLogin.tsx b/src/components/User/UserLogin.tsx index 39be5db..82d32b0 100644 --- a/src/components/User/UserLogin.tsx +++ b/src/components/User/UserLogin.tsx @@ -44,8 +44,9 @@ const UserLogin: React.FC = ({ const [unverifiedEmail, setUnverifiedEmail] = useState(""); // add const handleOAuthLogin = (provider: "google" | "orcid") => { - const apiUrl = process.env.REACT_APP_API_URL || "http://localhost:5000"; - window.location.href = `${apiUrl}/api/v1/auth/${provider}`; + const apiUrl = + process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1"; + window.location.href = `${apiUrl}/auth/${provider}`; }; const handleSubmit = async (e: React.FormEvent) => { diff --git a/src/components/User/UserSignup.tsx b/src/components/User/UserSignup.tsx index 2c82531..4a51b09 100644 --- a/src/components/User/UserSignup.tsx +++ b/src/components/User/UserSignup.tsx @@ -50,8 +50,9 @@ const UserSignup: React.FC = ({ const [successMessage, setSuccessMessage] = useState(""); const handleOAuthSignup = (provider: "google" | "orcid") => { - const apiUrl = process.env.REACT_APP_API_URL || "http://localhost:5000"; - window.location.href = `${apiUrl}/api/v1/auth/${provider}`; + const apiUrl = + process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1"; + window.location.href = `${apiUrl}/auth/${provider}`; }; const handleChange = diff --git a/src/pages/ResendVerification.tsx b/src/pages/ResendVerification.tsx index 709b809..55aa9f7 100644 --- a/src/pages/ResendVerification.tsx +++ b/src/pages/ResendVerification.tsx @@ -28,8 +28,8 @@ const ResendVerification: React.FC = () => { try { const response = await fetch( `${ - process.env.REACT_APP_API_URL || "http://localhost:5000" - }/api/v1/auth/resend-verification`, + process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1" + }/auth/resend-verification`, { method: "POST", headers: { diff --git a/src/pages/VerifyEmail.tsx b/src/pages/VerifyEmail.tsx index a715a84..75feb36 100644 --- a/src/pages/VerifyEmail.tsx +++ b/src/pages/VerifyEmail.tsx @@ -46,8 +46,8 @@ const VerifyEmail: React.FC = () => { try { const response = await fetch( `${ - process.env.REACT_APP_API_URL || "http://localhost:5000" - }/api/v1/auth/verify-email?token=${token}`, + process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1" + }/auth/verify-email?token=${token}`, { method: "GET", credentials: "include", From f7cbaf738c6460b8b0f4b1056c087a8c40cb425c Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 6 Jan 2026 14:46:55 -0500 Subject: [PATCH 33/46] update auth TypeScript interfaces --- src/redux/auth/types/auth.interface.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/redux/auth/types/auth.interface.ts b/src/redux/auth/types/auth.interface.ts index 419e799..64794a5 100644 --- a/src/redux/auth/types/auth.interface.ts +++ b/src/redux/auth/types/auth.interface.ts @@ -3,6 +3,10 @@ export interface User { username: string; email: string; email_verified: boolean; + firstName?: string; + lastName?: string; + company?: string; + interests?: string; created_at?: string; // (optional) updated_at?: string; // (optional) google_id?: string; // (optional, for OAuth) @@ -19,6 +23,10 @@ export interface SignupData { username: string; email: string; password: string; + firstName: string; // ← NEW + lastName: string; // ← NEW + company: string; // ← NEW + interests?: string; // ← NEW } // export interface AuthResponse { From 4645efc59fc93fd8dd932e9b1881135cd0b2cd7d Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 6 Jan 2026 14:57:45 -0500 Subject: [PATCH 34/46] update user sign up form to includes new fields --- src/components/User/UserSignup.tsx | 116 +++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/components/User/UserSignup.tsx b/src/components/User/UserSignup.tsx index 4a51b09..03fe910 100644 --- a/src/components/User/UserSignup.tsx +++ b/src/components/User/UserSignup.tsx @@ -38,6 +38,10 @@ const UserSignup: React.FC = ({ const [formData, setFormData] = useState({ username: "", email: "", + firstName: "", // ← NEW + lastName: "", // ← NEW + company: "", // ← NEW + interests: "", // ← NEW (optional) password: "", confirmPassword: "", }); @@ -64,6 +68,9 @@ const UserSignup: React.FC = ({ if ( !formData.username || !formData.email || + !formData.firstName || // ← NEW + !formData.lastName || // ← NEW + !formData.company || // ← NEW !formData.password || !formData.confirmPassword ) { @@ -105,6 +112,10 @@ const UserSignup: React.FC = ({ username: formData.username, email: formData.email, password: formData.password, + firstName: formData.firstName, // ← NEW + lastName: formData.lastName, // ← NEW + company: formData.company, // ← NEW + interests: formData.interests, // ← NEW }) ); if (signupUser.fulfilled.match(result)) { @@ -121,6 +132,10 @@ const UserSignup: React.FC = ({ setFormData({ username: "", email: "", + firstName: "", // ← NEW + lastName: "", // ← NEW + company: "", // ← NEW + interests: "", // ← NEW password: "", confirmPassword: "", }); @@ -145,6 +160,10 @@ const UserSignup: React.FC = ({ setFormData({ username: "", email: "", + firstName: "", // ← NEW + lastName: "", // ← NEW + company: "", // ← NEW + interests: "", // ← NEW password: "", confirmPassword: "", }); @@ -173,6 +192,7 @@ const UserSignup: React.FC = ({ backgroundColor: Colors.white, color: Colors.darkPurple, borderRadius: 2, + maxHeight: "90vh", // Allow scrolling if content is too tall }, }} > @@ -255,6 +275,102 @@ const UserSignup: React.FC = ({ }} /> + {/* First Name - NEW */} + + + {/* Last Name - NEW */} + + + {/* Institute - NEW */} + + + {/* Research Interests - NEW (Optional) */} + + Date: Tue, 6 Jan 2026 15:33:02 -0500 Subject: [PATCH 35/46] remove debug console.log --- src/components/User/UserSignup.tsx | 24 ++++++++++++------------ src/redux/auth/auth.action.ts | 1 - 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/components/User/UserSignup.tsx b/src/components/User/UserSignup.tsx index 03fe910..b0a0399 100644 --- a/src/components/User/UserSignup.tsx +++ b/src/components/User/UserSignup.tsx @@ -119,7 +119,6 @@ const UserSignup: React.FC = ({ }) ); if (signupUser.fulfilled.match(result)) { - // handleClose(); if (result.payload.requiresVerification) { // Traditional signup - show verification message setSuccess(true); @@ -219,17 +218,6 @@ const UserSignup: React.FC = ({ - {/* NEW: Success alert */} - {success && ( - - {successMessage} - - )} - {error && ( - - {error} - - )} = ({ }, }} /> + + {/* Success / error alert */} + {success && ( + + {successMessage} + + )} + {error && ( + + {error} + + )} + + + + ); +}; + +export default SecurityTab; diff --git a/src/components/User/UserButton.tsx b/src/components/User/UserButton.tsx index c15ca6c..5ece16e 100644 --- a/src/components/User/UserButton.tsx +++ b/src/components/User/UserButton.tsx @@ -176,9 +176,7 @@ const UserButton: React.FC = ({ /> )} - handleMenuItemClick(RoutesEnum.DASHBOARD)} - > + handleMenuItemClick(RoutesEnum.DASHBOARD)}> diff --git a/src/components/User/UserDashboard.tsx b/src/components/User/UserDashboard.tsx index e69de29..c4cab12 100644 --- a/src/components/User/UserDashboard.tsx +++ b/src/components/User/UserDashboard.tsx @@ -0,0 +1,129 @@ +import ProfileTab from "./Dashboard/ProfileTab"; +import SecurityTab from "./Dashboard/SecurityTab"; +import { AccountCircle, Lock, Settings } from "@mui/icons-material"; +import { + Box, + Container, + Paper, + Tabs, + Tab, + Typography, + Avatar, +} from "@mui/material"; +import { useAppSelector } from "hooks/useAppSelector"; +import React, { useState } from "react"; +import { AuthSelector } from "redux/auth/auth.selector"; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +const UserDashboard: React.FC = () => { + const [tabValue, setTabValue] = useState(0); + const { user } = useAppSelector(AuthSelector); + + const handleTabChange = (event: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + }; + + if (!user) { + return ( + + Please log in to access your dashboard. + + ); + } + + return ( + + {/* Header Section */} + + + + {user.firstName?.[0]?.toUpperCase() || + user.username[0].toUpperCase()} + + + + {user.firstName && user.lastName + ? `${user.firstName} ${user.lastName}` + : user.username} + + + + Email: {user.email} + + {user.company && ( + + Institution / Company: {user.company} + + )} + + + + + {/* Dashboard Content */} + + + } + label="Profile" + id="dashboard-tab-0" + aria-controls="dashboard-tabpanel-0" + /> + } + label="Security" + id="dashboard-tab-1" + aria-controls="dashboard-tabpanel-1" + /> + } + label="Settings" + id="dashboard-tab-2" + aria-controls="dashboard-tabpanel-2" + /> + + + + + + + + + + + ); +}; + +export default UserDashboard; diff --git a/src/redux/auth/auth.action.ts b/src/redux/auth/auth.action.ts index 96f9cd2..4ea146c 100644 --- a/src/redux/auth/auth.action.ts +++ b/src/redux/auth/auth.action.ts @@ -1,4 +1,8 @@ -import { LoginCredentials, SignupData } from "./types/auth.interface"; +import { + LoginCredentials, + SignupData, + ChangePasswordData, +} from "./types/auth.interface"; import { createAsyncThunk } from "@reduxjs/toolkit"; import { AuthService } from "services/auth.service"; @@ -54,3 +58,15 @@ export const signupUser = createAsyncThunk( } } ); + +export const changePassword = createAsyncThunk( + "auth/changePassword", + async (passwordData: ChangePasswordData, { rejectWithValue }) => { + try { + const message = await AuthService.changePassword(passwordData); + return message; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to change password"); + } + } +); diff --git a/src/redux/auth/auth.slice.ts b/src/redux/auth/auth.slice.ts index a43984a..e7ab353 100644 --- a/src/redux/auth/auth.slice.ts +++ b/src/redux/auth/auth.slice.ts @@ -3,6 +3,7 @@ import { getCurrentUser, logoutUser, signupUser, + changePassword, } from "./auth.action"; import { IAuthState, @@ -13,7 +14,7 @@ import { } from "./types/auth.interface"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -// ✅ ADD: Type guard function +// Type guard function export function isLoginErrorResponse(error: any): error is LoginErrorResponse { return ( typeof error === "object" && @@ -128,6 +129,20 @@ const authSlice = createSlice({ .addCase(signupUser.rejected, (state, action) => { state.loading = false; state.error = action.payload as string; + }) + // Change Password + .addCase(changePassword.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(changePassword.fulfilled, (state) => { + state.loading = false; + state.error = null; + // Password changed successfully - no state update needed + }) + .addCase(changePassword.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; }); }, }); diff --git a/src/redux/auth/types/auth.interface.ts b/src/redux/auth/types/auth.interface.ts index 64794a5..3795af8 100644 --- a/src/redux/auth/types/auth.interface.ts +++ b/src/redux/auth/types/auth.interface.ts @@ -7,6 +7,7 @@ export interface User { lastName?: string; company?: string; interests?: string; + isOAuthUser?: boolean; created_at?: string; // (optional) updated_at?: string; // (optional) google_id?: string; // (optional, for OAuth) @@ -29,12 +30,6 @@ export interface SignupData { interests?: string; // ← NEW } -// export interface AuthResponse { -// message: string; -// user: User; -// requiresVerification?: boolean; -// } - export interface SignupResponse { message: string; user: User; @@ -58,3 +53,8 @@ export interface IAuthState { loading: boolean; error: string | null; } + +export interface ChangePasswordData { + currentPassword: string; + newPassword: string; +} diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 10582a2..165a15b 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -1,11 +1,11 @@ import { - // AuthResponse, - SignupResponse, // ← Changed from AuthResponse - LoginResponse, // ← Added - LoginErrorResponse, // ← Added + SignupResponse, + LoginResponse, + LoginErrorResponse, LoginCredentials, SignupData, User, + ChangePasswordData, } from "redux/auth/types/auth.interface"; const API_URL = process.env.REACT_APP_API_URL || "http://localhost:5000/api/v1"; @@ -81,4 +81,22 @@ export const AuthService = { return data; }, + changePassword: async (passwordData: ChangePasswordData): Promise => { + const response = await fetch(`${API_URL}/auth/change-password`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(passwordData), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || "Failed to change password"); + } + + return data.message; + }, }; diff --git a/src/types/routes.enum.ts b/src/types/routes.enum.ts index a5e44a0..b37a84d 100644 --- a/src/types/routes.enum.ts +++ b/src/types/routes.enum.ts @@ -3,5 +3,6 @@ enum RoutesEnum { DATABASES = "/db", // New route for databases SEARCH = "/search", // New route for the search page ABOUT = "/about", // New route for the about page + DASHBOARD = "/dashboard", } export default RoutesEnum; From 6e30a4484fca19e67897b0d3f84d4f09c000bd67 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 9 Jan 2026 16:07:09 -0500 Subject: [PATCH 38/46] feat: add change password functionality to user dashboard --- backend/src/controllers/auth.controller.js | 11 ++++++++++- src/components/User/Dashboard/SecurityTab.tsx | 4 +++- src/components/User/UserButton.tsx | 6 +++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index df1b07c..e7cc73b 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -418,7 +418,7 @@ const completeProfile = async (req, res) => { const changePassword = async (req, res) => { try { const { currentPassword, newPassword } = req.body; - const user = req.user; + // const user = req.user; // Validation if (!currentPassword || !newPassword) { @@ -433,6 +433,15 @@ const changePassword = async (req, res) => { }); } + // REFETCH user with hashed_password field + // req.user only has basic info, we need to load the password field + const user = await User.findByPk(req.user.id); + if (!user) { + return res.status(404).json({ + message: "User not found", + }); + } + // Check if user has a password (OAuth users don't) if (!user.hashed_password) { return res.status(400).json({ diff --git a/src/components/User/Dashboard/SecurityTab.tsx b/src/components/User/Dashboard/SecurityTab.tsx index 84eba76..5cabf50 100644 --- a/src/components/User/Dashboard/SecurityTab.tsx +++ b/src/components/User/Dashboard/SecurityTab.tsx @@ -49,6 +49,8 @@ const SecurityTab: React.FC = ({ user }) => { const [validationErrors, setValidationErrors] = useState<{ [key: string]: string; }>({}); + console.log("current user", user); + console.log("user.isOAuthUser value:", user.isOAuthUser); const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -131,7 +133,7 @@ const SecurityTab: React.FC = ({ user }) => { }; // If OAuth user, show different message - if (user.isOAuthUser) { + if (user.isOAuthUser === true) { return ( diff --git a/src/components/User/UserButton.tsx b/src/components/User/UserButton.tsx index 5ece16e..3cd0801 100644 --- a/src/components/User/UserButton.tsx +++ b/src/components/User/UserButton.tsx @@ -183,14 +183,14 @@ const UserButton: React.FC = ({ Dashboard - handleMenuItemClick(RoutesEnum.SETTINGS)} + {/* handleMenuItemClick(RoutesEnum.SETTINGS)} > Settings - + */} {/* handleMenuItemClick(RoutesEnum.USER_MANAGEMENT)} From bd8c10cb23737cf987cb79fe3994daf4d4e15f80 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Fri, 9 Jan 2026 18:37:01 -0500 Subject: [PATCH 39/46] feat: add forgot password functionality --- src/components/Routes.tsx | 6 + src/components/User/Dashboard/SecurityTab.tsx | 14 -- src/components/User/ForgotPassword.tsx | 102 +++++++++ src/components/User/ResetPassword.tsx | 198 ++++++++++++++++++ src/components/User/UserLogin.tsx | 25 ++- src/redux/auth/auth.action.ts | 26 +++ src/redux/auth/auth.slice.ts | 32 +++ src/redux/auth/types/auth.interface.ts | 17 ++ src/services/auth.service.ts | 48 +++++ 9 files changed, 453 insertions(+), 15 deletions(-) create mode 100644 src/components/User/ForgotPassword.tsx create mode 100644 src/components/User/ResetPassword.tsx diff --git a/src/components/Routes.tsx b/src/components/Routes.tsx index e1ab346..3120159 100644 --- a/src/components/Routes.tsx +++ b/src/components/Routes.tsx @@ -1,5 +1,7 @@ import ScrollToTop from "./ScrollToTop"; import CompleteProfile from "./User/CompleteProfile"; +import ForgotPassword from "./User/ForgotPassword"; +import ResetPassword from "./User/ResetPassword"; import UserDashboard from "./User/UserDashboard"; import FullScreen from "design/Layouts/FullScreen"; import AboutPage from "pages/AboutPage"; @@ -53,6 +55,10 @@ const Routes = () => ( } /> + {/* forgot and reset password page */} + } /> + } /> + {/* Dashboard Page */} } /> diff --git a/src/components/User/Dashboard/SecurityTab.tsx b/src/components/User/Dashboard/SecurityTab.tsx index 5cabf50..797dde4 100644 --- a/src/components/User/Dashboard/SecurityTab.tsx +++ b/src/components/User/Dashboard/SecurityTab.tsx @@ -15,18 +15,6 @@ import { useAppDispatch } from "hooks/useAppDispatch"; import React, { useState } from "react"; import { changePassword } from "redux/auth/auth.action"; -// interface User { -// id: number; -// username: string; -// email: string; -// firstName?: string; -// lastName?: string; -// company?: string; -// interests?: string; -// email_verified: boolean; -// isOAuthUser: boolean; -// } - interface SecurityTabProps { user: User; } @@ -49,8 +37,6 @@ const SecurityTab: React.FC = ({ user }) => { const [validationErrors, setValidationErrors] = useState<{ [key: string]: string; }>({}); - console.log("current user", user); - console.log("user.isOAuthUser value:", user.isOAuthUser); const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; diff --git a/src/components/User/ForgotPassword.tsx b/src/components/User/ForgotPassword.tsx new file mode 100644 index 0000000..3ea2b2f --- /dev/null +++ b/src/components/User/ForgotPassword.tsx @@ -0,0 +1,102 @@ +import { + Container, + Paper, + TextField, + Button, + Typography, + Box, + Alert, + Link as MuiLink, +} from "@mui/material"; +import { useAppDispatch } from "hooks/useAppDispatch"; +import { useAppSelector } from "hooks/useAppSelector"; +import React, { useState } from "react"; +import { Link } from "react-router-dom"; +import { forgotPassword } from "redux/auth/auth.action"; + +const ForgotPassword: React.FC = () => { + const dispatch = useAppDispatch(); + const { loading } = useAppSelector((state) => state.auth); + const [email, setEmail] = useState(""); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setSuccess(false); + + try { + await dispatch(forgotPassword({ email })).unwrap(); + setSuccess(true); + setEmail(""); + } catch (err: any) { + setError(err); + } + }; + + return ( + + + + Forgot Password + + + + Enter your email address and we'll send you a link to reset your + password. + + + {success && ( + + If an account with that email exists, a password reset link has been + sent. Please check your email (and spam folder). + + )} + + {error && ( + + {error} + + )} + + + setEmail(e.target.value)} + required + disabled={loading || success} + sx={{ mb: 3 }} + placeholder="your.email@example.com" + /> + + + + + + ← Back to Login + + + + + + ); +}; + +export default ForgotPassword; diff --git a/src/components/User/ResetPassword.tsx b/src/components/User/ResetPassword.tsx new file mode 100644 index 0000000..a09630e --- /dev/null +++ b/src/components/User/ResetPassword.tsx @@ -0,0 +1,198 @@ +import { Visibility, VisibilityOff, CheckCircle } from "@mui/icons-material"; +import { + Container, + Paper, + TextField, + Button, + Typography, + Box, + Alert, + InputAdornment, + IconButton, + CircularProgress, +} from "@mui/material"; +import { useAppDispatch } from "hooks/useAppDispatch"; +import { useAppSelector } from "hooks/useAppSelector"; +import React, { useState, useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { resetPassword } from "redux/auth/auth.action"; + +const ResetPassword: React.FC = () => { + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const [searchParams] = useSearchParams(); + const token = searchParams.get("token"); + + const { loading } = useAppSelector((state) => state.auth); + + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(""); + const [countdown, setCountdown] = useState(3); + + // Check token on mount + useEffect(() => { + if (!token) { + setError("Invalid reset link. Please request a new password reset."); + } + }, [token]); + + // Countdown and redirect after success + useEffect(() => { + if (success && countdown > 0) { + const timer = setTimeout(() => setCountdown(countdown - 1), 1000); + return () => clearTimeout(timer); + } else if (success && countdown === 0) { + navigate("/?login=true"); + } + }, [success, countdown, navigate]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + // Validation + if (password.length < 8) { + setError("Password must be at least 8 characters long"); + return; + } + + if (password !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + if (!token) { + setError("Invalid reset token"); + return; + } + + try { + await dispatch(resetPassword({ token, password })).unwrap(); + setSuccess(true); + setPassword(""); + setConfirmPassword(""); + } catch (err: any) { + setError(err); + } + }; + + return ( + + + + Reset Password + + + + Enter your new password below. + + + {success && ( + } + > + + Password reset successful! + + + Redirecting to login in {countdown} seconds... + + + )} + + {error && ( + + {error} + + )} + + {token && !success && ( + + setPassword(e.target.value)} + required + disabled={loading} + sx={{ mb: 2 }} + InputProps={{ + endAdornment: ( + + setShowPassword(!showPassword)} + edge="end" + disabled={loading} + > + {showPassword ? : } + + + ), + }} + helperText="Must be at least 8 characters" + /> + + setConfirmPassword(e.target.value)} + required + disabled={loading} + sx={{ mb: 3 }} + error={confirmPassword.length > 0 && password !== confirmPassword} + helperText={ + confirmPassword.length > 0 && password !== confirmPassword + ? "Passwords do not match" + : "" + } + /> + + + + )} + + {success && ( + + + + )} + + + ); +}; + +export default ResetPassword; diff --git a/src/components/User/UserLogin.tsx b/src/components/User/UserLogin.tsx index 82d32b0..32a0f1d 100644 --- a/src/components/User/UserLogin.tsx +++ b/src/components/User/UserLogin.tsx @@ -204,7 +204,8 @@ const UserLogin: React.FC = ({ ), }} sx={{ - mb: 3, + // mb: 3, + mb: 1, "& .MuiOutlinedInput-root": { color: Colors.darkPurple, "& fieldset": { borderColor: Colors.primary.light }, @@ -217,6 +218,28 @@ const UserLogin: React.FC = ({ }, }} /> + + {/* FORGOT PASSWORD LINK */} + + { + handleClose(); // Close the modal + navigate("/forgot-password"); // Navigate to forgot password page + }} + > + Forgot Password? + + - - ← Back to Login + + ← Back to Home diff --git a/src/components/User/ResetPassword.tsx b/src/components/User/ResetPassword.tsx index a09630e..db0e10f 100644 --- a/src/components/User/ResetPassword.tsx +++ b/src/components/User/ResetPassword.tsx @@ -11,6 +11,7 @@ import { IconButton, CircularProgress, } from "@mui/material"; +import { Colors } from "design/theme"; import { useAppDispatch } from "hooks/useAppDispatch"; import { useAppSelector } from "hooks/useAppSelector"; import React, { useState, useEffect } from "react"; @@ -126,7 +127,19 @@ const ResetPassword: React.FC = () => { onChange={(e) => setPassword(e.target.value)} required disabled={loading} - sx={{ mb: 2 }} + sx={{ + mb: 2, + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + "& .MuiInputLabel-root.Mui-focused": { + color: Colors.purple, + }, + "& .MuiInputBase-input": { + caretColor: Colors.purple, + }, + }} InputProps={{ endAdornment: ( @@ -151,7 +164,19 @@ const ResetPassword: React.FC = () => { onChange={(e) => setConfirmPassword(e.target.value)} required disabled={loading} - sx={{ mb: 3 }} + sx={{ + mb: 3, + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": + { + borderColor: Colors.purple, + }, + "& .MuiInputLabel-root.Mui-focused": { + color: Colors.purple, + }, + "& .MuiInputBase-input": { + caretColor: Colors.purple, + }, + }} error={confirmPassword.length > 0 && password !== confirmPassword} helperText={ confirmPassword.length > 0 && password !== confirmPassword @@ -165,7 +190,13 @@ const ResetPassword: React.FC = () => { variant="contained" type="submit" disabled={loading} - sx={{ py: 1.5 }} + sx={{ + py: 1.5, + backgroundColor: Colors.purple, + "&:hover": { + backgroundColor: Colors.secondaryPurple, + }, + }} > {loading ? ( <> @@ -184,7 +215,15 @@ const ResetPassword: React.FC = () => { diff --git a/src/components/User/UserDashboard.tsx b/src/components/User/UserDashboard.tsx index c4cab12..6759fb5 100644 --- a/src/components/User/UserDashboard.tsx +++ b/src/components/User/UserDashboard.tsx @@ -10,6 +10,7 @@ import { Typography, Avatar, } from "@mui/material"; +import { Colors } from "design/theme"; import { useAppSelector } from "hooks/useAppSelector"; import React, { useState } from "react"; import { AuthSelector } from "redux/auth/auth.selector"; @@ -47,7 +48,9 @@ const UserDashboard: React.FC = () => { if (!user) { return ( - Please log in to access your dashboard. + + Please log in to access your dashboard. + ); } @@ -61,7 +64,7 @@ const UserDashboard: React.FC = () => { sx={{ width: 80, height: 80, - bgcolor: "primary.main", + bgcolor: Colors.purple, fontSize: "2rem", }} > @@ -76,11 +79,11 @@ const UserDashboard: React.FC = () => { - Email: {user.email} + {user.email} {user.company && ( - Institution / Company: {user.company} + {user.company} )} @@ -93,7 +96,19 @@ const UserDashboard: React.FC = () => { value={tabValue} onChange={handleTabChange} aria-label="dashboard tabs" - sx={{ borderBottom: 1, borderColor: "divider" }} + sx={{ + borderBottom: 1, + borderColor: "divider", + "& .MuiTab-root": { + color: "text.secondary", // unselected + }, + "& .MuiTab-root.Mui-selected": { + color: Colors.darkGreen, // selected text + icon + }, + "& .MuiTabs-indicator": { + backgroundColor: Colors.darkGreen, // control the bottom line + }, + }} > } From e0a214f620dfe2e64d9aa5c14fe180bc9f6269e6 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 13 Jan 2026 13:49:24 -0500 Subject: [PATCH 41/46] fix: allow hybrid users(password + OAuth) to change password --- .github/workflows/build-deploy-neurojsonio.yml | 5 ++++- backend/src/controllers/auth.controller.js | 11 +++++++++-- src/components/User/Dashboard/SecurityTab.tsx | 3 ++- src/redux/auth/types/auth.interface.ts | 1 + 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-deploy-neurojsonio.yml b/.github/workflows/build-deploy-neurojsonio.yml index 5d749ff..db1c4d4 100644 --- a/.github/workflows/build-deploy-neurojsonio.yml +++ b/.github/workflows/build-deploy-neurojsonio.yml @@ -24,7 +24,10 @@ jobs: - name: Build React App for production run: | echo "Building for production at root /" - PUBLIC_URL="/" yarn build + # PUBLIC_URL="/" yarn build + export PUBLIC_URL="/" + export REACT_APP_API_URL="/api/v1" + yarn build - name: Copy JS libraries run: | diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index e7cc73b..7db9506 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -224,12 +224,18 @@ const login = async (req, res) => { res.status(200).json({ message: "Login successful", - // token, user: { id: user.id, username: user.username, email: user.email, email_verified: user.email_verified, + firstName: user.first_name, + lastName: user.last_name, + company: user.company, + interests: user.interests, + isOAuthUser: !!(user.google_id || user.orcid_id || user.github_id), + created_at: user.created_at, + updated_at: user.updated_at, }, }); } catch (error) { @@ -260,7 +266,8 @@ const getCurrentUser = async (req, res) => { lastName: user.last_name, company: user.company, interests: user.interests, - isOAuthUser: !!(user.google_id || user.orcid_id || user.github_id), // new + isOAuthUser: !!(user.google_id || user.orcid_id || user.github_id), + hasPassword: !!user.hashed_password, // new add created_at: user.created_at, updated_at: user.updated_at, }, diff --git a/src/components/User/Dashboard/SecurityTab.tsx b/src/components/User/Dashboard/SecurityTab.tsx index 2ce41fb..1e7b970 100644 --- a/src/components/User/Dashboard/SecurityTab.tsx +++ b/src/components/User/Dashboard/SecurityTab.tsx @@ -38,6 +38,7 @@ const SecurityTab: React.FC = ({ user }) => { const [validationErrors, setValidationErrors] = useState<{ [key: string]: string; }>({}); + const isOAuthOnly = user.isOAuthUser && !user.hasPassword; const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -120,7 +121,7 @@ const SecurityTab: React.FC = ({ user }) => { }; // If OAuth user, show different message - if (user.isOAuthUser === true) { + if (isOAuthOnly) { return ( diff --git a/src/redux/auth/types/auth.interface.ts b/src/redux/auth/types/auth.interface.ts index 9d9096f..f4e4e65 100644 --- a/src/redux/auth/types/auth.interface.ts +++ b/src/redux/auth/types/auth.interface.ts @@ -8,6 +8,7 @@ export interface User { company?: string; interests?: string; isOAuthUser?: boolean; + hasPassword?: boolean; created_at?: string; // (optional) updated_at?: string; // (optional) google_id?: string; // (optional, for OAuth) From 5b098145f512ecb975bea2aebda356c9a609b116 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 14 Jan 2026 11:05:37 -0500 Subject: [PATCH 42/46] feat: add password validator function --- backend/src/utils/passwordValidator.js | 56 +++++++++++++++++++ src/components/SearchPage/SubjectCard.tsx | 3 +- .../SearchPageFunctions/modalityLabels.ts | 4 ++ .../SearchPageFunctions/searchformSchema.ts | 4 ++ 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 backend/src/utils/passwordValidator.js diff --git a/backend/src/utils/passwordValidator.js b/backend/src/utils/passwordValidator.js new file mode 100644 index 0000000..bb6dd46 --- /dev/null +++ b/backend/src/utils/passwordValidator.js @@ -0,0 +1,56 @@ +const validatePassword = (password) => { + if (!password) { + return { isValid: false, message: "Password is required" }; + } + + if (password.length < 8) { + return { + isValid: false, + message: "Password must be at least 8 characters long", + }; + } + + if (password.length > 128) { + return { + isValid: false, + message: "Password must not exceed 128 characters", + }; + } + + // Check for at least one uppercase letter + if (!/[A-Z]/.test(password)) { + return { + isValid: false, + message: "Password must contain at least one uppercase letter", + }; + } + + // Check for at least one lowercase letter + if (!/[a-z]/.test(password)) { + return { + isValid: false, + message: "Password must contain at least one lowercase letter", + }; + } + + // Check for at least one number + if (!/\d/.test(password)) { + return { + isValid: false, + message: "Password must contain at least one number", + }; + } + + // Check for at least one special character + if (!/[!@#$%^&*()_+\-=\[\]{};:'",.<>?/\\|`~]/.test(password)) { + return { + isValid: false, + message: + "Password must contain at least one special character (!@#$%^&*...)", + }; + } + + return { isValid: true, message: "Password is valid" }; +}; + +module.exports = { validatePassword }; diff --git a/src/components/SearchPage/SubjectCard.tsx b/src/components/SearchPage/SubjectCard.tsx index a05aa48..c66ebce 100644 --- a/src/components/SearchPage/SubjectCard.tsx +++ b/src/components/SearchPage/SubjectCard.tsx @@ -225,7 +225,8 @@ const SubjectCard: React.FC = ({ - Sessions: {sessions?.length} + Sessions:{" "} + {sessions?.length === 0 ? 1 : sessions?.length} diff --git a/src/utils/SearchPageFunctions/modalityLabels.ts b/src/utils/SearchPageFunctions/modalityLabels.ts index 532c3be..628c24f 100644 --- a/src/utils/SearchPageFunctions/modalityLabels.ts +++ b/src/utils/SearchPageFunctions/modalityLabels.ts @@ -15,4 +15,8 @@ export const modalityValueToEnumLabel: Record = { nirs: "fNIRS (nirs)", fnirs: "fNIRS (nirs)", motion: "motion (motion)", + ephys: "Electrophysiology (ephys)", // Add + atlas: "Atlas (atlas)", // Add + // nifti: "NIfTI (nifti)", + // mesh: "Mesh (mesh)", }; diff --git a/src/utils/SearchPageFunctions/searchformSchema.ts b/src/utils/SearchPageFunctions/searchformSchema.ts index b3e1a97..7fc71b5 100644 --- a/src/utils/SearchPageFunctions/searchformSchema.ts +++ b/src/utils/SearchPageFunctions/searchformSchema.ts @@ -50,6 +50,10 @@ export const baseSchema: JSONSchema7 = { "motion (motion)", "behavdata", "hpi", + "Electrophysiology (ephys)", // Add + "Atlas (atlas)", // Add + // "NIfTI (nifti)", + // "Mesh (mesh)", "any", ], default: "any", From 06305b7903a22adaa37c7358f6e4e720c10b503e Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 14 Jan 2026 11:16:56 -0500 Subject: [PATCH 43/46] apply password validator to auth controllers --- backend/src/controllers/auth.controller.js | 38 +++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 7db9506..61a8615 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -4,6 +4,7 @@ const crypto = require("crypto"); const { setTokenCookie } = require("../middleware/auth.middleware"); const emailService = require("../../services/email.service"); const { Op } = require("sequelize"); +const { validatePassword } = require("../utils/passwordValidator"); // register new user const register = async (req, res) => { @@ -52,10 +53,19 @@ const register = async (req, res) => { }); } - if (password && password.length < 8) { - return res.status(400).json({ - message: "Password must be at least 8 characters long", - }); + // if (password && password.length < 8) { + // return res.status(400).json({ + // message: "Password must be at least 8 characters long", + // }); + // } + // password validate + if (password) { + const passwordValidation = validatePassword(password); + if (!passwordValidation.isValid) { + return res.status(400).json({ + message: passwordValidation.message, + }); + } } // check if email already exists @@ -434,9 +444,15 @@ const changePassword = async (req, res) => { }); } - if (newPassword.length < 8) { + // if (newPassword.length < 8) { + // return res.status(400).json({ + // message: "New password must be at least 8 characters long", + // }); + // } + const passwordValidation = validatePassword(newPassword); + if (!passwordValidation.isValid) { return res.status(400).json({ - message: "New password must be at least 8 characters long", + message: passwordValidation.message, }); } @@ -534,9 +550,15 @@ const resetPassword = async (req, res) => { return res.status(400).json({ message: "Password is required" }); } - if (password.length < 8) { + // if (password.length < 8) { + // return res.status(400).json({ + // message: "Password must be at least 8 characters long", + // }); + // } + const passwordValidation = validatePassword(password); + if (!passwordValidation.isValid) { return res.status(400).json({ - message: "Password must be at least 8 characters long", + message: passwordValidation.message, }); } From 7fa9eaa3c2e2c09df7d76ed51cc0734f9637a960 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 14 Jan 2026 11:32:07 -0500 Subject: [PATCH 44/46] feat: add password strength indicator util and component --- .../User/PasswordStrengthIndicator.tsx | 72 ++++++++++++++++ src/utils/passwordValidator.ts | 83 +++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 src/components/User/PasswordStrengthIndicator.tsx create mode 100644 src/utils/passwordValidator.ts diff --git a/src/components/User/PasswordStrengthIndicator.tsx b/src/components/User/PasswordStrengthIndicator.tsx new file mode 100644 index 0000000..d57dd64 --- /dev/null +++ b/src/components/User/PasswordStrengthIndicator.tsx @@ -0,0 +1,72 @@ +import { validatePasswordWithDetails } from "../../utils/passwordValidator"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import CircleOutlinedIcon from "@mui/icons-material/CircleOutlined"; +import { Box, Typography } from "@mui/material"; +import React from "react"; + +interface PasswordStrengthIndicatorProps { + password: string; +} + +const PasswordStrengthIndicator: React.FC = ({ + password, +}) => { + // Use the validator utility to get validation results + const validation = validatePasswordWithDetails(password); + + // Define rule labels (matches the validation rules) + const ruleLabels = [ + { key: "minLength", label: "At least 8 characters" }, + { key: "hasUppercase", label: "One uppercase letter (A-Z)" }, + { key: "hasLowercase", label: "One lowercase letter (a-z)" }, + { key: "hasNumber", label: "One number (0-9)" }, + { key: "hasSpecialChar", label: "One special character (!@#$%...)" }, + ]; + + return ( + + + Password must contain: + + {ruleLabels.map((rule) => { + const isMet = + validation.rules[rule.key as keyof typeof validation.rules]; + + return ( + + {isMet ? ( + + ) : ( + + )} + + {rule.label} + + + ); + })} + + ); +}; + +export default PasswordStrengthIndicator; diff --git a/src/utils/passwordValidator.ts b/src/utils/passwordValidator.ts new file mode 100644 index 0000000..b1988d2 --- /dev/null +++ b/src/utils/passwordValidator.ts @@ -0,0 +1,83 @@ +export interface PasswordValidationResult { + isValid: boolean; + rules: { + minLength: boolean; + hasUppercase: boolean; + hasLowercase: boolean; + hasNumber: boolean; + hasSpecialChar: boolean; + }; +} + +export const validatePasswordWithDetails = ( + password: string +): PasswordValidationResult => { + const rules = { + minLength: password.length >= 8, + hasUppercase: /[A-Z]/.test(password), + hasLowercase: /[a-z]/.test(password), + hasNumber: /\d/.test(password), + hasSpecialChar: /[!@#$%^&*()_+\-=\[\]{};:'",.<>?/\\|`~]/.test(password), + }; + + const isValid = Object.values(rules).every(Boolean); + + return { + isValid, + rules, + }; +}; + +// Simple validation that returns a message (matches backend style) +// Used for form submission validation +export const validatePassword = ( + password: string +): { isValid: boolean; message: string } => { + if (!password) { + return { isValid: false, message: "Password is required" }; + } + + if (password.length < 8) { + return { + isValid: false, + message: "Password must be at least 8 characters long", + }; + } + + if (password.length > 128) { + return { + isValid: false, + message: "Password must not exceed 128 characters", + }; + } + + if (!/[A-Z]/.test(password)) { + return { + isValid: false, + message: "Password must contain at least one uppercase letter", + }; + } + + if (!/[a-z]/.test(password)) { + return { + isValid: false, + message: "Password must contain at least one lowercase letter", + }; + } + + if (!/\d/.test(password)) { + return { + isValid: false, + message: "Password must contain at least one number", + }; + } + + if (!/[!@#$%^&*()_+\-=\[\]{};:'",.<>?/\\|`~]/.test(password)) { + return { + isValid: false, + message: "Password must contain at least one special character", + }; + } + + return { isValid: true, message: "Password is valid" }; +}; From 950b75e073325a1246f5d648ad62a5bf18d4b3e1 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 14 Jan 2026 12:14:27 -0500 Subject: [PATCH 45/46] feat: apply password validator UI in security tab, reset password and user sign up form --- src/components/User/Dashboard/SecurityTab.tsx | 27 ++++++++++++++----- src/components/User/ResetPassword.tsx | 19 ++++++++++--- src/components/User/UserSignup.tsx | 25 +++++++++++++---- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/components/User/Dashboard/SecurityTab.tsx b/src/components/User/Dashboard/SecurityTab.tsx index 1e7b970..0ec8f66 100644 --- a/src/components/User/Dashboard/SecurityTab.tsx +++ b/src/components/User/Dashboard/SecurityTab.tsx @@ -1,4 +1,5 @@ import { User } from "../../../redux/auth/types/auth.interface"; +import PasswordStrengthIndicator from "../PasswordStrengthIndicator"; import { Visibility, VisibilityOff } from "@mui/icons-material"; import { Box, @@ -15,6 +16,7 @@ import { Colors } from "design/theme"; import { useAppDispatch } from "hooks/useAppDispatch"; import React, { useState } from "react"; import { changePassword } from "redux/auth/auth.action"; +import { validatePassword } from "utils/passwordValidator"; interface SecurityTabProps { user: User; @@ -72,10 +74,19 @@ const SecurityTab: React.FC = ({ user }) => { errors.currentPassword = "Current password is required"; } + // if (!formData.newPassword) { + // errors.newPassword = "New password is required"; + // } else if (formData.newPassword.length < 8) { + // errors.newPassword = "Password must be at least 8 characters long"; + // } if (!formData.newPassword) { errors.newPassword = "New password is required"; - } else if (formData.newPassword.length < 8) { - errors.newPassword = "Password must be at least 8 characters long"; + } else { + // password validator + const passwordValidation = validatePassword(formData.newPassword); + if (!passwordValidation.isValid) { + errors.newPassword = passwordValidation.message; + } } if (!formData.confirmPassword) { @@ -204,10 +215,7 @@ const SecurityTab: React.FC = ({ user }) => { value={formData.newPassword} onChange={handleChange} error={!!validationErrors.newPassword} - helperText={ - validationErrors.newPassword || - "Must be at least 8 characters long" - } + // helperText={validationErrors.newPassword} sx={{ mb: 2, "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": @@ -235,6 +243,13 @@ const SecurityTab: React.FC = ({ user }) => { }} /> + {/* ADD PASSWORD STRENGTH INDICATOR */} + {formData.newPassword && ( + + + + )} + {/* Confirm Password */} { const navigate = useNavigate(); @@ -55,8 +57,13 @@ const ResetPassword: React.FC = () => { setError(""); // Validation - if (password.length < 8) { - setError("Password must be at least 8 characters long"); + // if (password.length < 8) { + // setError("Password must be at least 8 characters long"); + // return; + // } + const passwordValidation = validatePassword(password); + if (!passwordValidation.isValid) { + setError(passwordValidation.message); return; } @@ -153,9 +160,15 @@ const ResetPassword: React.FC = () => { ), }} - helperText="Must be at least 8 characters" /> + {/* PASSWORD STRENGTH INDICATOR */} + {password && ( + + + + )} + = ({ if ( !formData.username || !formData.email || - !formData.firstName || // ← NEW - !formData.lastName || // ← NEW - !formData.company || // ← NEW + !formData.firstName || + !formData.lastName || + !formData.company || !formData.password || !formData.confirmPassword ) { @@ -78,8 +83,13 @@ const UserSignup: React.FC = ({ return false; } - if (formData.password.length < 8) { - setError("Password must be at least 8 characters long"); + // if (formData.password.length < 8) { + // setError("Password must be at least 8 characters long"); + // return false; + // } + const passwordValidation = validatePassword(formData.password); + if (!passwordValidation.isValid) { + setError(passwordValidation.message); return false; } @@ -394,6 +404,10 @@ const UserSignup: React.FC = ({ }, }} /> + {/*password validate */} + {formData.password && ( + + )} = ({ ), }} sx={{ + mt: 2, mb: 2, "& .MuiOutlinedInput-root": { color: Colors.darkPurple, From 047916f7a77f23f32de24720f1490d9fa58d9737 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 14 Jan 2026 16:25:59 -0500 Subject: [PATCH 46/46] fix: add haspassword field to login controller --- backend/src/controllers/auth.controller.js | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 61a8615..45d7abf 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -244,6 +244,7 @@ const login = async (req, res) => { company: user.company, interests: user.interests, isOAuthUser: !!(user.google_id || user.orcid_id || user.github_id), + hasPassword: !!user.hashed_password, // add created_at: user.created_at, updated_at: user.updated_at, },