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-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/.github/workflows/build-deploy-zodiac.yml b/.github/workflows/build-deploy-zodiac.yml
index ca0bcca..197421c 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 }}/api/v1"
+ 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/config/passport.config.js b/backend/config/passport.config.js
new file mode 100644
index 0000000..043ba56
--- /dev/null
+++ b/backend/config/passport.config.js
@@ -0,0 +1,160 @@
+// backend/config/passport.config.js
+const passport = require("passport");
+const GoogleStrategy = require("passport-google-oauth20").Strategy;
+// const { User } = require("../models");
+const User = require("../src/models/User");
+
+// 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];
+
+ // 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 },
+ });
+
+ 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;
+ // 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);
+ }
+
+ // Create new user
+ user = await User.create({
+ username: username,
+ email: email,
+ 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);
+ } 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,
+// email_verified: true,
+// });
+
+// 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/migrations/20251028184256-create-users.js b/backend/migrations/20251028184256-create-users.js
index 7ff7f25..2126508 100644
--- a/backend/migrations/20251028184256-create-users.js
+++ b/backend/migrations/20251028184256-create-users.js
@@ -45,6 +45,46 @@ 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,
+ },
+ // NEW FIELDS FOR PASSWORD RESET
+ reset_password_token: {
+ type: Sequelize.STRING(255),
+ allowNull: true,
+ unique: true,
+ },
+ reset_password_expires: {
+ type: Sequelize.DATE,
+ allowNull: true,
+ },
+ first_name: {
+ type: Sequelize.STRING(255),
+ allowNull: false,
+ },
+ last_name: {
+ type: Sequelize.STRING(255),
+ allowNull: false,
+ },
+ company: {
+ type: Sequelize.STRING(255),
+ allowNull: false,
+ },
+ interests: {
+ type: Sequelize.TEXT,
+ allowNull: true,
+ },
created_at: {
type: Sequelize.DATE,
allowNull: false,
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/package-lock.json b/backend/package-lock.json
index 4989a14..423ee9c 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -16,6 +16,10 @@
"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",
"pg": "^8.16.3",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.7",
@@ -396,6 +400,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",
@@ -2418,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",
@@ -2490,6 +2512,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 +2592,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 +2721,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 +3817,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 +3900,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..d4d9b83 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -29,6 +29,10 @@
"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",
"pg": "^8.16.3",
"pg-hstore": "^2.3.4",
"sequelize": "^6.37.7",
diff --git a/backend/services/email.service.js b/backend/services/email.service.js
new file mode 100644
index 0000000..92c9f63
--- /dev/null
+++ b/backend/services/email.service.js
@@ -0,0 +1,255 @@
+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 {
+ 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);
+ return { success: false };
+ }
+ }
+
+ 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 `
+
+
+
+
+
+
+
+
+
+
+
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 `
+
+
+
+
+
+
+
+
+
+
+
Hi ${username}!
+
Your email has been verified successfully! Welcome to the NeuroJSON.io community.
+
You can now:
+
+ - 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
+
+
+
Happy researching! 🧠
+
+
+
+
+
+ `;
+ }
+
+ getPasswordResetTemplate(firstName, resetUrl) {
+ return `
+
+
+
+
+
+
+
+
+
+
+
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/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js
index 19b4735..45d7abf 100644
--- a/backend/src/controllers/auth.controller.js
+++ b/backend/src/controllers/auth.controller.js
@@ -1,15 +1,26 @@
-const jwt = require("jsonwebtoken");
const { User } = require("../models");
const bcrypt = require("bcrypt");
+const crypto = require("crypto");
const { setTokenCookie } = require("../middleware/auth.middleware");
-
-const JWT_SECRET = process.env.JWT_SECRET;
+const emailService = require("../../services/email.service");
+const { Op } = require("sequelize");
+const { validatePassword } = require("../utils/passwordValidator");
// 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;
@@ -21,6 +32,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({
@@ -35,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
@@ -77,23 +104,56 @@ 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
+ first_name: firstName || "", // NEW
+ last_name: lastName || "", // NEW
+ company: company || "", // NEW
+ interests: interests || null, // NEW
});
- // generate JWT token
- // const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, {
- // expiresIn: "1h",
- // });
+ // 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,
+ firstName: user.first_name, // NEW
+ lastName: user.last_name, // NEW
+ company: user.company, // NEW
+ interests: user.interests, // NEW
+ },
+ requiresVerification: true,
+ });
+ }
- //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,
+ firstName: user.first_name, // NEW
+ lastName: user.last_name, // NEW
+ company: user.company, // NEW
+ interests: user.interests, // NEW
},
});
} catch (error) {
@@ -159,21 +219,34 @@ 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({
+ 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);
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),
+ hasPassword: !!user.hashed_password, // add
+ created_at: user.created_at,
+ updated_at: user.updated_at,
},
});
} catch (error) {
@@ -199,6 +272,13 @@ const getCurrentUser = async (req, res) => {
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),
+ hasPassword: !!user.hashed_password, // new add
created_at: user.created_at,
updated_at: user.updated_at,
},
@@ -210,32 +290,6 @@ const getCurrentUser = async (req, res) => {
error: error.message,
});
}
-
- // try {
- // const userId = req.userId;
- // const user = await User.findByPk(userId, {
- // attributes: [
- // "id",
- // "username",
- // "email",
- // "created_at",
- // "orcid_id",
- // "google_id",
- // "github_id",
- // ],
- // });
- // if (!user) {
- // return res.status(404).json({
- // message: "User not found",
- // });
- // }
- // res.status(200).json({ user });
- // } catch (error) {
- // console.error("Get current user error:", error);
- // res
- // .status(500)
- // .json({ message: "Error fetching user", error: error.message });
- // }
};
// logout user
@@ -255,9 +309,300 @@ 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,
+ });
+ }
+};
+
+// 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" });
+ }
+};
+
+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",
+ // });
+ // }
+ const passwordValidation = validatePassword(newPassword);
+ if (!passwordValidation.isValid) {
+ return res.status(400).json({
+ message: passwordValidation.message,
+ });
+ }
+
+ // 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({
+ 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" });
+ }
+};
+
+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" });
+ }
+};
+
+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",
+ // });
+ // }
+ const passwordValidation = validatePassword(password);
+ if (!passwordValidation.isValid) {
+ return res.status(400).json({
+ message: passwordValidation.message,
+ });
+ }
+
+ // 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,
getCurrentUser,
logout,
+ resendVerificationEmail,
+ completeProfile,
+ changePassword,
+ forgotPassword, // New
+ resetPassword,
};
diff --git a/backend/src/controllers/oauth.controller.js b/backend/src/controllers/oauth.controller.js
new file mode 100644
index 0000000..1826e77
--- /dev/null
+++ b/backend/src/controllers/oauth.controller.js
@@ -0,0 +1,102 @@
+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) => {
+ // 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`
+ );
+ }
+
+ // 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);
+
+ // 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/controllers/verification.controller.js b/backend/src/controllers/verification.controller.js
new file mode 100644
index 0000000..d0a9f38
--- /dev/null
+++ b/backend/src/controllers/verification.controller.js
@@ -0,0 +1,85 @@
+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",
+ expired: false,
+ });
+ }
+
+ // 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",
+ expired: false,
+ });
+ }
+
+ // check if already verified
+ if (user.email_verified) {
+ return res.status(400).json({
+ message: "Email is already verified",
+ expired: false,
+ });
+ }
+
+ // 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.",
+ expired: true,
+ });
+ }
+ // 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,
+ expired: false,
+ });
+ }
+};
+
+module.exports = {
+ verifyEmail,
+};
diff --git a/backend/src/models/User.js b/backend/src/models/User.js
index a79431d..ba87089 100644
--- a/backend/src/models/User.js
+++ b/backend/src/models/User.js
@@ -1,16 +1,92 @@
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;
+ }
+
+ // Generate password reset token
+ generateResetPasswordToken() {
+ const token = crypto.randomBytes(32).toString("hex");
+ this.reset_password_token = crypto
+ .createHash("sha256")
+ .update(token)
+ .digest("hex");
+ this.reset_password_expires = new Date(Date.now() + 3600000); // 1 hour
+ return token;
+ }
+
+ // Verify if reset token is valid
+ isResetPasswordTokenValid(token) {
+ if (!this.reset_password_token || !this.reset_password_expires) {
+ return false; // no token exists
+ }
+ const hashedToken = crypto.createHash("sha256").update(token).digest("hex");
+ return (
+ hashedToken === this.reset_password_token &&
+ this.reset_password_expires > new Date()
+ );
+ }
+
+ // Clear reset token
+ clearResetPasswordToken() {
+ this.reset_password_token = null;
+ 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}`;
+ }
}
User.init(
@@ -52,6 +128,46 @@ 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,
+ },
+ // 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,
diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js
index b251668..ebbb82d 100644
--- a/backend/src/routes/auth.routes.js
+++ b/backend/src/routes/auth.routes.js
@@ -1,19 +1,80 @@
// request send to postgres
const express = require("express");
+const passport = require("passport");
const {
register,
login,
getCurrentUser,
logout,
+ resendVerificationEmail,
+ completeProfile,
+ changePassword,
+ forgotPassword,
+ resetPassword,
} = require("../controllers/auth.controller");
-// const { authenticateToken } = require("../middleware/auth.middleware");
+const { verifyEmail } = require("../controllers/verification.controller");
+const {
+ googleAuth,
+ googleCallback,
+ // orcidAuth,
+ // orcidCallback,
+} = require("../controllers/oauth.controller");
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);
+
+// 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",
+ 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;
diff --git a/backend/src/server.js b/backend/src/server.js
index 2d6767f..47e87bf 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");
@@ -15,15 +16,25 @@ 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
+app.use(passport.initialize());
// restore user on every request
app.use(restoreUser);
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/App.tsx b/src/App.tsx
index 9ddbd35..28b62f3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,16 +1,115 @@
-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 { BrowserRouter } from "react-router-dom";
+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() {
useGAPageviews();
return null;
}
+// Component to handle auth checks within Router context
+// 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 = () => {
+ 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")) {
+ navigate("/", { replace: true });
+ }
+ };
+
+ window.addEventListener("popstate", handlePopState);
+
+ 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) {
+ dispatch(getCurrentUser());
+ }
+ };
+
+ window.addEventListener("pageshow", handlePageShow);
+ return () => window.removeEventListener("pageshow", handlePageShow);
+ }, [dispatch]);
+
+ // Handle OAuth callback
+ useEffect(() => {
+ const searchParams = new URLSearchParams(location.search);
+ const authStatus = searchParams.get("auth");
+
+ // Only process OAuth callback once per session
+ if (authStatus && !hasProcessedOAuthRef.current) {
+ hasProcessedOAuthRef.current = true; // Mark as processed
+
+ if (authStatus === "success") {
+ dispatch(getCurrentUser());
+ } else if (authStatus === "error") {
+ const errorMessage =
+ searchParams.get("message") || "Authentication failed";
+ }
+
+ // Clean up URL - this removes it from history
+ // window.history.replaceState({}, "", window.location.pathname);
+ // navigate(window.location.pathname, { replace: true });
+ navigate(location.pathname, { replace: true });
+ }
+ }, [location.search, dispatch, navigate]);
+
+ return null;
+}
+
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 (
{
/>
+
diff --git a/src/components/NavBar/NavItems.tsx b/src/components/NavBar/NavItems.tsx
index c27dbb8..ac80f59 100644
--- a/src/components/NavBar/NavItems.tsx
+++ b/src/components/NavBar/NavItems.tsx
@@ -1,20 +1,32 @@
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 { useAppDispatch } from "hooks/useAppDispatch";
+import { useAppSelector } from "hooks/useAppSelector";
+import React, { useState, useEffect } from "react";
import { useNavigate, Link } from "react-router-dom";
+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";
const NavItems: React.FC = () => {
const navigate = useNavigate();
+ const dispatch = useAppDispatch();
+
+ const auth = useAppSelector(AuthSelector);
+ const { isLoggedIn, user } = auth;
+ const userName = user?.username || "";
+
+ // Modal state
+ const [loginOpen, setLoginOpen] = useState(false);
+ const [signupOpen, setSignupOpen] = useState(false);
- // for user test
- const isLoggedIn = false;
- const userName = "John Doe";
const handleLogout = () => {
- // TODO: Implement your logout logic here
- console.log("Logging out...");
- // Example: dispatch(logout()) or authService.logout()
+ dispatch(logoutUser());
+ navigate("/");
};
return (
@@ -27,85 +39,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 +229,75 @@ 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);
}}
- >
- {/* */}
-
- {/* */}
- {/* */}
- {/* */}
- {/* */}
-
+ />
+ setSignupOpen(false)}
+ onSwitchToLogin={() => {
+ setSignupOpen(false);
+ setLoginOpen(true);
+ }}
+ />
+ >
);
};
diff --git a/src/components/Routes.tsx b/src/components/Routes.tsx
index 54c3e24..3120159 100644
--- a/src/components/Routes.tsx
+++ b/src/components/Routes.tsx
@@ -1,13 +1,19 @@
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";
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 +48,19 @@ const Routes = () => (
{/* About Page */}
} />
+
+ {/* Email Verification Routes */}
+ } />
+ } />
+
+ } />
+
+ {/* forgot and reset password page */}
+ } />
+ } />
+
+ {/* Dashboard Page */}
+ } />
>
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/components/User/CompleteProfile.tsx b/src/components/User/CompleteProfile.tsx
new file mode 100644
index 0000000..537b2f2
--- /dev/null
+++ b/src/components/User/CompleteProfile.tsx
@@ -0,0 +1,274 @@
+import {
+ Container,
+ Paper,
+ TextField,
+ Button,
+ Typography,
+ Box,
+ Alert,
+ 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";
+
+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("");
+ const [tokenExpired, setTokenExpired] = useState(false);
+
+ 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("");
+ setTokenExpired(false);
+ setLoading(true);
+
+ try {
+ 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", { 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`;
+ } catch (err: unknown) {
+ // setError(err.response?.data?.message || 'Failed to complete profile');
+ if (axios.isAxiosError(err)) {
+ 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");
+ }
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ Complete Your Profile
+
+
+ Just a few more details to get you started on NeuroJSON.io
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {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
+
+
+
+
+ )}
+
+
+ );
+};
+
+export default CompleteProfile;
diff --git a/src/components/User/Dashboard/ProfileTab.tsx b/src/components/User/Dashboard/ProfileTab.tsx
new file mode 100644
index 0000000..03debf2
--- /dev/null
+++ b/src/components/User/Dashboard/ProfileTab.tsx
@@ -0,0 +1,179 @@
+import {
+ Email,
+ Person,
+ Business,
+ CalendarToday,
+ Verified,
+} from "@mui/icons-material";
+import { Box, Typography, Grid, Paper, Chip, Divider } from "@mui/material";
+import React from "react";
+
+interface User {
+ id: number;
+ username: string;
+ email: string;
+ firstName?: string;
+ lastName?: string;
+ company?: string;
+ interests?: string;
+ email_verified: boolean;
+ created_at?: string;
+ updated_at?: string;
+}
+
+interface ProfileTabProps {
+ user: User;
+}
+
+const ProfileTab: React.FC = ({ user }) => {
+ const formatDate = (dateString?: string) => {
+ if (!dateString) return "N/A";
+ return new Date(dateString).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ };
+
+ return (
+
+
+ Profile Information
+
+
+
+
+ {/* Username */}
+
+
+
+
+
+ Username
+
+ {user.username}
+
+
+
+
+ {/* Email */}
+
+
+
+
+
+ Email
+
+
+ {user.email}
+ {user.email_verified && (
+ }
+ label="Verified"
+ size="small"
+ color="success"
+ sx={{ height: 20 }}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+ {/* First Name */}
+ {user.firstName && (
+
+
+ First Name
+
+ {user.firstName}
+
+ )}
+
+ {/* Last Name */}
+ {user.lastName && (
+
+
+ Last Name
+
+ {user.lastName}
+
+ )}
+
+ {/* Company */}
+ {user.company && (
+
+
+
+
+
+ Company/Institution
+
+ {user.company}
+
+
+
+ )}
+
+ {/* Interests */}
+ {user.interests && (
+
+
+ Research Interests
+
+
+ {user.interests}
+
+
+ )}
+
+
+
+
+
+ {/* Account Created */}
+
+
+
+
+
+ Member Since
+
+
+ {formatDate(user.created_at)}
+
+
+
+
+
+ {/* Last Updated */}
+ {user.updated_at && (
+
+
+ Last Updated
+
+
+ {formatDate(user.updated_at)}
+
+
+ )}
+
+
+
+ {/* Note about editing */}
+
+ To update your profile information, please contact support.
+
+
+ );
+};
+
+export default ProfileTab;
diff --git a/src/components/User/Dashboard/SecurityTab.tsx b/src/components/User/Dashboard/SecurityTab.tsx
new file mode 100644
index 0000000..0ec8f66
--- /dev/null
+++ b/src/components/User/Dashboard/SecurityTab.tsx
@@ -0,0 +1,311 @@
+import { User } from "../../../redux/auth/types/auth.interface";
+import PasswordStrengthIndicator from "../PasswordStrengthIndicator";
+import { Visibility, VisibilityOff } from "@mui/icons-material";
+import {
+ Box,
+ Typography,
+ TextField,
+ Button,
+ Alert,
+ Paper,
+ InputAdornment,
+ IconButton,
+ CircularProgress,
+} from "@mui/material";
+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;
+}
+
+const SecurityTab: React.FC = ({ user }) => {
+ const dispatch = useAppDispatch();
+ const [formData, setFormData] = useState({
+ currentPassword: "",
+ newPassword: "",
+ confirmPassword: "",
+ });
+ const [showPasswords, setShowPasswords] = useState({
+ current: false,
+ new: false,
+ confirm: false,
+ });
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+ const [validationErrors, setValidationErrors] = useState<{
+ [key: string]: string;
+ }>({});
+ const isOAuthOnly = user.isOAuthUser && !user.hasPassword;
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({
+ ...prev,
+ [name]: value,
+ }));
+ // Clear error for this field
+ if (validationErrors[name]) {
+ setValidationErrors((prev) => {
+ const newErrors = { ...prev };
+ delete newErrors[name];
+ return newErrors;
+ });
+ }
+ setError(null);
+ setSuccess(null);
+ };
+
+ const toggleShowPassword = (field: "current" | "new" | "confirm") => {
+ setShowPasswords((prev) => ({
+ ...prev,
+ [field]: !prev[field],
+ }));
+ };
+
+ const validateForm = (): boolean => {
+ const errors: { [key: string]: string } = {};
+
+ if (!formData.currentPassword) {
+ 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 {
+ // password validator
+ const passwordValidation = validatePassword(formData.newPassword);
+ if (!passwordValidation.isValid) {
+ errors.newPassword = passwordValidation.message;
+ }
+ }
+
+ if (!formData.confirmPassword) {
+ errors.confirmPassword = "Please confirm your new password";
+ } else if (formData.newPassword !== formData.confirmPassword) {
+ errors.confirmPassword = "Passwords do not match";
+ }
+
+ setValidationErrors(errors);
+ return Object.keys(errors).length === 0;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setSuccess(null);
+
+ if (!validateForm()) {
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ const result = await dispatch(
+ changePassword({
+ currentPassword: formData.currentPassword,
+ newPassword: formData.newPassword,
+ })
+ ).unwrap();
+
+ setSuccess(result || "Password changed successfully!");
+ setFormData({
+ currentPassword: "",
+ newPassword: "",
+ confirmPassword: "",
+ });
+ } catch (err: any) {
+ setError(err || "Failed to change password");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // If OAuth user, show different message
+ if (isOAuthOnly) {
+ return (
+
+
+ Security Settings
+
+
+ You're using OAuth login (Google/GitHub/ORCID). Password management is
+ handled by your OAuth provider.
+
+
+ );
+ }
+
+ return (
+
+
+ Change Password
+
+
+ Update your password to keep your account secure
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {success && (
+
+ {success}
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default SecurityTab;
diff --git a/src/components/User/Dashboard/SettingsTab.tsx b/src/components/User/Dashboard/SettingsTab.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/User/ForgotPassword.tsx b/src/components/User/ForgotPassword.tsx
new file mode 100644
index 0000000..0e46e8c
--- /dev/null
+++ b/src/components/User/ForgotPassword.tsx
@@ -0,0 +1,134 @@
+import {
+ Container,
+ Paper,
+ TextField,
+ Button,
+ Typography,
+ Box,
+ Alert,
+ Link as MuiLink,
+} from "@mui/material";
+import { Colors } from "design/theme";
+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,
+ "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline":
+ {
+ borderColor: Colors.purple,
+ },
+ "& .MuiInputLabel-root.Mui-focused": {
+ color: Colors.purple,
+ },
+ "& .MuiInputBase-input": {
+ caretColor: Colors.purple,
+ },
+ }}
+ placeholder="your.email@example.com"
+ />
+
+
+
+
+
+ ← Back to Home
+
+
+
+
+
+ );
+};
+
+export default ForgotPassword;
diff --git a/src/components/User/GoogleButton.tsx b/src/components/User/GoogleButton.tsx
new file mode 100644
index 0000000..0e9dd87
--- /dev/null
+++ b/src/components/User/GoogleButton.tsx
@@ -0,0 +1,58 @@
+import { Box, Button } from "@mui/material";
+import { Colors } from "design/theme";
+import React from "react";
+
+interface GoogleButtonProps {
+ onClick: () => void;
+ disabled?: boolean;
+ // variant?: "signin" | "signup";
+}
+
+const GoogleButton: React.FC = ({
+ onClick,
+ disabled = false,
+ // variant = "signin",
+}) => {
+ return (
+
+ );
+};
+
+export default GoogleButton;
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/components/User/ResetPassword.tsx b/src/components/User/ResetPassword.tsx
new file mode 100644
index 0000000..18ef8af
--- /dev/null
+++ b/src/components/User/ResetPassword.tsx
@@ -0,0 +1,250 @@
+import PasswordStrengthIndicator from "./PasswordStrengthIndicator";
+import { Visibility, VisibilityOff, CheckCircle } from "@mui/icons-material";
+import {
+ Container,
+ Paper,
+ TextField,
+ Button,
+ Typography,
+ Box,
+ Alert,
+ InputAdornment,
+ 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";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import { resetPassword } from "redux/auth/auth.action";
+import { validatePassword } from "utils/passwordValidator";
+
+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;
+ // }
+ const passwordValidation = validatePassword(password);
+ if (!passwordValidation.isValid) {
+ setError(passwordValidation.message);
+ 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,
+ "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline":
+ {
+ borderColor: Colors.purple,
+ },
+ "& .MuiInputLabel-root.Mui-focused": {
+ color: Colors.purple,
+ },
+ "& .MuiInputBase-input": {
+ caretColor: Colors.purple,
+ },
+ }}
+ InputProps={{
+ endAdornment: (
+
+ setShowPassword(!showPassword)}
+ edge="end"
+ disabled={loading}
+ >
+ {showPassword ? : }
+
+
+ ),
+ }}
+ />
+
+ {/* PASSWORD STRENGTH INDICATOR */}
+ {password && (
+
+
+
+ )}
+
+ setConfirmPassword(e.target.value)}
+ required
+ disabled={loading}
+ 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
+ ? "Passwords do not match"
+ : ""
+ }
+ />
+
+
+
+ )}
+
+ {success && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default ResetPassword;
diff --git a/src/components/User/UserButton.tsx b/src/components/User/UserButton.tsx
index 3438533..3cd0801 100644
--- a/src/components/User/UserButton.tsx
+++ b/src/components/User/UserButton.tsx
@@ -1,11 +1,13 @@
import {
- AccountCircle, // Login,
- // PersonAdd,
+ AccountCircle,
Dashboard,
Settings,
- ManageAccounts, // Logout,
+ ManageAccounts,
+ Logout,
} from "@mui/icons-material";
import {
+ Box,
+ Typography,
IconButton,
Menu,
MenuItem,
@@ -23,12 +25,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 +48,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 (
<>
= ({
anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
PaperProps={{
sx: {
- backgroundColor: Colors.lightBlue,
+ backgroundColor: Colors.white,
color: Colors.darkPurple,
minWidth: 200,
mt: 1.5,
@@ -99,23 +127,13 @@ const UserButton: React.FC = ({
>
{!isLoggedIn ? (
<>
-