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 ` + + + + + + + +
+
+

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:

+
+ Verify Email Address +
+

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:

+ +
+ Start Exploring +
+

Happy researching! 🧠

+
+ +
+ + + `; + } + + 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:

+
+ Reset 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} + + )} + + +
+ {/* Current Password */} + + toggleShowPassword("current")} + edge="end" + > + {showPasswords.current ? : } + + + ), + }} + /> + + {/* New Password */} + + toggleShowPassword("new")} + edge="end" + > + {showPasswords.new ? : } + + + ), + }} + /> + + {/* ADD PASSWORD STRENGTH INDICATOR */} + {formData.newPassword && ( + + + + )} + + {/* Confirm Password */} + + toggleShowPassword("confirm")} + edge="end" + > + {showPasswords.confirm ? : } + + + ), + }} + /> + + + +
+
+ ); +}; + +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 ? ( <> - handleMenuItemClick(RoutesEnum.LOGIN)} - > - {/* - - */} + Sign In - - handleMenuItemClick(RoutesEnum.SIGNUP)} - > - {/* - - */} + + Create Account @@ -124,68 +142,74 @@ const UserButton: React.FC = ({ <> {userName && ( <> - - + Welcome, + + - + > + {userName} + + )} - handleMenuItemClick(RoutesEnum.DASHBOARD)} - > + handleMenuItemClick(RoutesEnum.DASHBOARD)}> Dashboard - handleMenuItemClick(RoutesEnum.SETTINGS)} + {/* handleMenuItemClick(RoutesEnum.SETTINGS)} > Settings - + */} - handleMenuItemClick(RoutesEnum.USER_MANAGEMENT)} + {/* handleMenuItemClick(RoutesEnum.USER_MANAGEMENT)} > User Management - + */} - - {/* - - */} + Logout + + + )} diff --git a/src/components/User/UserDashboard.tsx b/src/components/User/UserDashboard.tsx new file mode 100644 index 0000000..6759fb5 --- /dev/null +++ b/src/components/User/UserDashboard.tsx @@ -0,0 +1,144 @@ +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 { Colors } from "design/theme"; +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} + + + + {user.email} + + {user.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/components/User/UserLogin.tsx b/src/components/User/UserLogin.tsx index e69de29..32a0f1d 100644 --- a/src/components/User/UserLogin.tsx +++ b/src/components/User/UserLogin.tsx @@ -0,0 +1,301 @@ +import GoogleButton from "./GoogleButton"; +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 { 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, isLoginErrorResponse } from "redux/auth/auth.slice"; + +interface UserLoginProps { + open: boolean; + onClose: () => void; + onSwitchToSignup: () => void; +} + +const UserLogin: React.FC = ({ + open, + onClose, + onSwitchToSignup, +}) => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); // add + 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 [unverifiedEmail, setUnverifiedEmail] = useState(""); // add + + const handleOAuthLogin = (provider: "google" | "orcid") => { + 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) => { + e.preventDefault(); + setError(""); + setUnverifiedEmail(""); // add + dispatch(clearError()); + const result = await dispatch(loginUser({ email, password })); + if (loginUser.fulfilled.match(result)) { + // Success - close modal + handleClose(); + } else { + // ✅ 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(); + }; + + const handleSwitchToSignup = () => { + handleClose(); + onSwitchToSignup(); + }; + + return ( + + + + Sign In + + + + + + + + {error && ( + + {error} + {/* ✅ NEW: Show "Resend Email" button only for unverified email error */} + {unverifiedEmail && ( + + + + )} + + )} + 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, + mb: 1, + "& .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 }, + }, + }} + /> + + {/* FORGOT PASSWORD LINK */} + + { + handleClose(); // Close the modal + navigate("/forgot-password"); // Navigate to forgot password page + }} + > + Forgot Password? + + + + + handleOAuthLogin("google")} + disabled={loading} + /> + {/* handleOAuthLogin("orcid")} + disabled={loading} + /> */} + + + + 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..631ab10 100644 --- a/src/components/User/UserSignup.tsx +++ b/src/components/User/UserSignup.tsx @@ -0,0 +1,518 @@ +import GoogleButton from "./GoogleButton"; +import PasswordStrengthIndicator from "./PasswordStrengthIndicator"; +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 { 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"; +// for password validate +import { validatePassword } from "utils/passwordValidator"; + +// password validate + +interface UserSignupProps { + open: boolean; + onClose: () => void; + onSwitchToLogin: () => void; +} + +const UserSignup: React.FC = ({ + open, + onClose, + onSwitchToLogin, +}) => { + const dispatch = useAppDispatch(); + const auth = useAppSelector(AuthSelector); + const { loading, error: reduxError } = auth; + + const [formData, setFormData] = useState({ + username: "", + email: "", + firstName: "", // ← NEW + lastName: "", // ← NEW + company: "", // ← NEW + interests: "", // ← NEW (optional) + password: "", + confirmPassword: "", + }); + const [showPassword, setShowPassword] = useState(false); + 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/api/v1"; + window.location.href = `${apiUrl}/auth/${provider}`; + }; + + const handleChange = + (field: string) => (e: React.ChangeEvent) => { + setFormData({ ...formData, [field]: e.target.value }); + }; + + const validateForm = () => { + if ( + !formData.username || + !formData.email || + !formData.firstName || + !formData.lastName || + !formData.company || + !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; + // } + const passwordValidation = validatePassword(formData.password); + if (!passwordValidation.isValid) { + setError(passwordValidation.message); + 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 handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setSuccess(false); + dispatch(clearError()); + + if (!validateForm()) { + return; + } + + const result = await dispatch( + signupUser({ + 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)) { + 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: "", + firstName: "", // ← NEW + lastName: "", // ← NEW + company: "", // ← NEW + interests: "", // ← NEW + 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( + (result.payload as string) || "Signup failed. Please try again." + ); + } + }; + + const handleClose = () => { + setFormData({ + username: "", + email: "", + firstName: "", // ← NEW + lastName: "", // ← NEW + company: "", // ← NEW + interests: "", // ← NEW + password: "", + confirmPassword: "", + }); + setError(""); + setSuccess(false); // ← NEW + setSuccessMessage(""); // ← NEW + setShowPassword(false); + setShowConfirmPassword(false); + dispatch(clearError()); + onClose(); + }; + + const handleSwitchToLogin = () => { + handleClose(); + onSwitchToLogin(); + }; + + return ( + + + + Create Account + + + + + + + + + + + + {/* First Name - NEW */} + + + {/* Last Name - NEW */} + + + {/* Institute - NEW */} + + + {/* Research Interests - NEW (Optional) */} + + + + 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 }, + }, + }} + /> + {/*password validate */} + {formData.password && ( + + )} + + setShowConfirmPassword(!showConfirmPassword)} + edge="end" + sx={{ color: Colors.primary.light }} + > + {showConfirmPassword ? : } + + + ), + }} + sx={{ + mt: 2, + 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 }, + }, + }} + /> + + {/* Success / error alert */} + {success && ( + + {successMessage} + + )} + {error && ( + + {error} + + )} + + + handleOAuthSignup("google")} + disabled={loading} + /> + {/* handleOAuthSignup("orcid")} + disabled={loading} + /> */} + + + + Already have an account?{" "} + + Sign In + + + + + + + ); +}; + +export default UserSignup; 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; diff --git a/src/pages/ResendVerification.tsx b/src/pages/ResendVerification.tsx new file mode 100644 index 0000000..55aa9f7 --- /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 new file mode 100644 index 0000000..75feb36 --- /dev/null +++ b/src/pages/VerifyEmail.tsx @@ -0,0 +1,199 @@ +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, useRef } 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); + + //add + const hasVerified = useRef(false); + + useEffect(() => { + // prevent duplicate verification attempts + if (hasVerified.current) { + return; + } + const verifyEmail = async () => { + const token = searchParams.get("token"); + if (!token) { + setStatus("error"); + setMessage("No verification token provided"); + return; + } + //mark as processing + hasVerified.current = true; + + 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; diff --git a/src/redux/auth/auth.action.ts b/src/redux/auth/auth.action.ts new file mode 100644 index 0000000..dac57a3 --- /dev/null +++ b/src/redux/auth/auth.action.ts @@ -0,0 +1,98 @@ +import { + LoginCredentials, + SignupData, + ChangePasswordData, + ForgotPasswordData, + ResetPasswordData, +} 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; + 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"); + } + } +); + +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"); + } + } +); + +export const logoutUser = createAsyncThunk( + "auth/logout", + async (_, { rejectWithValue }) => { + try { + await AuthService.logout(); + return null; + } catch (error: any) { + return rejectWithValue(error.message || "Logout failed"); + } + } +); + +export const signupUser = createAsyncThunk( + "auth/register", + async (signupData: SignupData, { rejectWithValue }) => { + try { + const response = await AuthService.signup(signupData); + return response; + } catch (error: any) { + return rejectWithValue(error.message || "Signup failed"); + } + } +); + +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"); + } + } +); + +export const forgotPassword = createAsyncThunk( + "auth/forgotPassword", + async (data: ForgotPasswordData, { rejectWithValue }) => { + try { + const response = await AuthService.forgotPassword(data); + return response; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to send reset email"); + } + } +); + +export const resetPassword = createAsyncThunk( + "auth/resetPassword", + async (data: ResetPasswordData, { rejectWithValue }) => { + try { + const response = await AuthService.resetPassword(data); + return response; + } catch (error: any) { + return rejectWithValue(error.message || "Failed to reset password"); + } + } +); 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..51373e7 --- /dev/null +++ b/src/redux/auth/auth.slice.ts @@ -0,0 +1,184 @@ +import { + loginUser, + getCurrentUser, + logoutUser, + signupUser, + changePassword, + forgotPassword, + resetPassword, +} from "./auth.action"; +import { + IAuthState, + User, + LoginResponse, + SignupResponse, + LoginErrorResponse, + ForgotPasswordResponse, + ResetPasswordResponse, +} from "./types/auth.interface"; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +// 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, + 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.user; + state.error = null; + } + ) + .addCase(loginUser.rejected, (state, action) => { + state.loading = false; + // 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) => { + 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; + }) + // 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; + }) + // 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; + 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; + }) + // 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; + }) + // Forgot Password + .addCase(forgotPassword.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(forgotPassword.fulfilled, (state) => { + state.loading = false; + state.error = null; + // Success - no state update needed + }) + .addCase(forgotPassword.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }) + // Reset Password + .addCase(resetPassword.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(resetPassword.fulfilled, (state) => { + state.loading = false; + state.error = null; + // Success - no state update needed + }) + .addCase(resetPassword.rejected, (state, action) => { + state.loading = false; + 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..f4e4e65 --- /dev/null +++ b/src/redux/auth/types/auth.interface.ts @@ -0,0 +1,78 @@ +export interface User { + id: number; + username: string; + email: string; + email_verified: boolean; + firstName?: string; + lastName?: string; + company?: string; + interests?: string; + isOAuthUser?: boolean; + hasPassword?: 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 { + email: string; + password: string; +} + +export interface SignupData { + username: string; + email: string; + password: string; + firstName: string; // ← NEW + lastName: string; // ← NEW + company: string; // ← NEW + interests?: string; // ← NEW +} + +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; + loading: boolean; + error: string | null; +} + +export interface ChangePasswordData { + currentPassword: string; + newPassword: string; +} + +export interface ForgotPasswordData { + email: string; +} + +export interface ForgotPasswordResponse { + message: string; +} + +export interface ResetPasswordData { + token: string; + password: string; +} + +export interface ResetPasswordResponse { + message: string; +} 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..524b49c --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,150 @@ +import { + SignupResponse, + LoginResponse, + LoginErrorResponse, + LoginCredentials, + SignupData, + User, + ChangePasswordData, + ForgotPasswordData, + ForgotPasswordResponse, + ResetPasswordData, + ResetPasswordResponse, +} 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) { + // 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; + }, + + 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; + }, + + logout: async (): Promise => { + const response = await fetch(`${API_URL}/auth/logout`, { + method: "POST", + credentials: "include", + }); + + if (!response.ok) { + 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; + }, + 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; + }, + forgotPassword: async ( + data: ForgotPasswordData + ): Promise => { + const response = await fetch(`${API_URL}/auth/forgot-password`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(data), + }); + + const responseData = await response.json(); + + if (!response.ok) { + throw new Error(responseData.message || "Failed to send reset email"); + } + + return responseData; + }, + + resetPassword: async ( + data: ResetPasswordData + ): Promise => { + const response = await fetch( + `${API_URL}/auth/reset-password?token=${data.token}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ password: data.password }), + } + ); + + const responseData = await response.json(); + + if (!response.ok) { + throw new Error(responseData.message || "Failed to reset password"); + } + + return responseData; + }, +}; 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; 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", 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" }; +};