diff --git a/initdb/schema.sql b/initdb/schema.sql index 55c417cd4d5f6794e2a2b4e3b5a4f6fd1d7e871b..21ea3825609294f1dac2eaf526906aea8eddc050 100644 --- a/initdb/schema.sql +++ b/initdb/schema.sql @@ -7,6 +7,8 @@ CREATE TABLE users ( email VARCHAR(50) UNIQUE, password VARCHAR(250), role VARCHAR(10), + isVerified BOOLEAN DEFAULT FALSE, + verificationToken VARCHAR(255), -- This column stores the verification token dateCreated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE videos( diff --git a/login-server.js b/login-server.js index d2fa6f99c3989b49cf2fad8bc12a280965e5fcbb..9e529bb35ce8391ca5ddb497d738df13658bf30f 100644 --- a/login-server.js +++ b/login-server.js @@ -3,7 +3,9 @@ import express from "express"; import cors from "cors"; import bcrypt from "bcryptjs"; // For hashing passwords import jwt from "jsonwebtoken"; // For generating tokens - +import nodemailer from "nodemailer"; +import dotenv from "dotenv"; +dotenv.config(); const app = express(); const port = 8081; @@ -12,6 +14,11 @@ if (process.env.DATABASE_HOST) { dbHost = process.env.DATABASE_HOST; } +let frontendUrl = "http://localhost:8081"; // Default for development +if (process.env.VITE_FRONTEND_URL) { + frontendUrl = process.env.VITE_FRONTEND_URL; // Use environment variable in production +} + // Middleware to parse incoming JSON requests app.use(express.json()); @@ -20,6 +27,19 @@ app.use(cors()); import dbRequest from "./db.js"; + +// Nodemailer setup +const emailUser = process.env.VITE_EMAIL_USER; +const emailPassword = process.env.VITE_EMAIL_PASSWORD; + +const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: emailUser, // Replace with your email + pass: emailPassword, // Replace with your app password( watch the video to know how to get app password) + }, +}); + // Signup Route export const signup = async (req, res) => { const db = dbRequest(dbHost); @@ -65,10 +85,22 @@ export const signup = async (req, res) => { return res.status(500).json({ message: "Server error" }); } + // Generate a verification token + const verificationToken = jwt.sign({ email }, "secretkey", { + expiresIn: "1d", + }); + // Insert new user into the database const query = - "INSERT INTO users (username, email, password, role) VALUES (?, ?, ?, ?)"; - const values = [username, email, hashedPassword, "user"]; + "INSERT INTO users (username, email, password, role, isVerified, verificationToken) VALUES (?, ?, ?, ?, ?, ?)"; + const values = [ + username, + email, + hashedPassword, + "user", + false, + verificationToken, + ]; db.query(query, values, (err, result) => { if (err) { @@ -78,9 +110,25 @@ export const signup = async (req, res) => { .status(500) .json({ message: "Database error", error: err }); } - db.destroy(); - return res.status(201).json({ - message: "User signed up successfully", + // Send verification email + const verificationLink = `${frontendUrl}/verify-email?token=${verificationToken}`; // Change to your frontend URL when deploying + const mailOptions = { + from: emailUser, // your email + to: email, + subject: "Verify Your Email", + text: `Click this link to verify your email: ${verificationLink}`, + }; + + transporter.sendMail(mailOptions, (error, info) => { + if (error) { + console.error("Error sending email: ", error); + return res.status(500).json({ message: "Error sending email" }); + } + db.destroy(); + return res.status(201).json({ + message: + "User signed up successfully. Please check your email to verify your account.", + }); }); }); }); @@ -97,6 +145,38 @@ export const signup = async (req, res) => { return res.status(500).json({ message: "Database error", error }); }); }; +// Email Verification Route +app.get("/verify-email", (req, res) => { + const db = dbRequest(dbHost); + const { token } = req.query; + + if (!token) { + db.destroy(); + return res.status(400).json({ message: "Missing token" }); + } + + jwt.verify(token, "secretkey", (err, decoded) => { + if (err) { + db.destroy(); + return res.status(400).json({ message: "Invalid or expired token" }); + } + + const email = decoded.email; + const updateQuery = + "UPDATE users SET isVerified = true, verificationToken = NULL WHERE email = ?"; + + db.query(updateQuery, [email], (err, result) => { + if (err) { + db.destroy(); + return res.status(500).json({ message: "Database error" }); + } + db.destroy(); + return res + .status(200) + .json({ message: "Email verified successfully! You can now log in." }); + }); + }); +}); const authenticateTokenGet = (req, res, next) => { const { auth: token } = req.query; @@ -143,6 +223,13 @@ app.post("/login", (req, res) => { const user = results[0]; + // Check if user is verified + if (!user.isVerified) { + return res + .status(403) + .json({ message: "Please verify your email before logging in." }); + } + // Compare passwords bcrypt.compare(password, user.password, (err, isMatch) => { if (err) { diff --git a/package-lock.json b/package-lock.json index 21a8965f95fe370f96e2d94b4c32db82c10ebe0e..27ce9ac5ad2a363bffa0f1853a69b24312f878a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "fs": "^0.0.1-security", "get-all-files": "^5.0.0", "i": "^0.3.7", + "jest-mock": "^29.7.0", "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", "mkdirp": "^3.0.1", @@ -33,6 +34,7 @@ "mysql": "^2.18.1", "mysql2": "^3.12.0", "node": "^18.20.6", + "nodemailer": "^6.10.0", "nodemon": "^3.1.9", "path-browserify": "^1.0.1", "react": "^18.3.1", @@ -69,7 +71,7 @@ "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5", - "vitest": "^3.0.5" + "vitest": "^3.0.8" } }, "node_modules/@adobe/css-tools": { @@ -1335,6 +1337,35 @@ } } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -2053,6 +2084,12 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "license": "MIT" + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -2236,6 +2273,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2338,6 +2399,21 @@ "license": "MIT", "optional": true }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.24.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz", @@ -2578,15 +2654,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.5.tgz", - "integrity": "sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.8.tgz", + "integrity": "sha512-Xu6TTIavTvSSS6LZaA3EebWFr6tsoXPetOWNMOlc7LO88QVVBwq2oQWBoDiLCN6YTvNYsGSjqOO8CAdjom5DCQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.5", - "@vitest/utils": "3.0.5", - "chai": "^5.1.2", + "@vitest/spy": "3.0.8", + "@vitest/utils": "3.0.8", + "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, "funding": { @@ -2594,13 +2670,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.5.tgz", - "integrity": "sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.8.tgz", + "integrity": "sha512-n3LjS7fcW1BCoF+zWZxG7/5XvuYH+lsFg+BDwwAz0arIwHQJFUEsKBQ0BLU49fCxuM/2HSeBPHQD8WjgrxMfow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.5", + "@vitest/spy": "3.0.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -2621,9 +2697,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.5.tgz", - "integrity": "sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.8.tgz", + "integrity": "sha512-BNqwbEyitFhzYMYHUVbIvepOyeQOSFA/NeJMIP9enMntkkxLgOcgABH6fjyXG85ipTgvero6noreavGIqfJcIg==", "dev": true, "license": "MIT", "dependencies": { @@ -2634,38 +2710,38 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.5.tgz", - "integrity": "sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.8.tgz", + "integrity": "sha512-c7UUw6gEcOzI8fih+uaAXS5DwjlBaCJUo7KJ4VvJcjL95+DSR1kova2hFuRt3w41KZEFcOEiq098KkyrjXeM5w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.5", - "pathe": "^2.0.2" + "@vitest/utils": "3.0.8", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.5.tgz", - "integrity": "sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.8.tgz", + "integrity": "sha512-x8IlMGSEMugakInj44nUrLSILh/zy1f2/BgH0UeHpNyOocG18M9CWVIFBaXPt8TrqVZWmcPjwfG/ht5tnpba8A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.5", + "@vitest/pretty-format": "3.0.8", "magic-string": "^0.30.17", - "pathe": "^2.0.2" + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.5.tgz", - "integrity": "sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.8.tgz", + "integrity": "sha512-MR+PzJa+22vFKYb934CejhR4BeRpMSoxkvNoDit68GQxRLSf11aT6CTj3XaqUU9rxgWJFnqicN/wxw6yBRkI1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2676,14 +2752,14 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.5.tgz", - "integrity": "sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.8.tgz", + "integrity": "sha512-nkBC3aEhfX2PdtQI/QwAWp8qZWwzASsU4Npbcd5RdMPBSSLCpkZp52P3xku3s3uA0HIEhGvEcF8rNkBsz9dQ4Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.5", - "loupe": "^3.1.2", + "@vitest/pretty-format": "3.0.8", + "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, "funding": { @@ -3203,6 +3279,21 @@ "node": ">= 16" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -4727,6 +4818,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -5157,6 +5254,37 @@ "dev": true, "license": "ISC" }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6118,6 +6246,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz", + "integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", @@ -8481,16 +8618,16 @@ } }, "node_modules/vite-node": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.5.tgz", - "integrity": "sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.8.tgz", + "integrity": "sha512-6PhR4H9VGlcwXZ+KWCdMqbtG649xCPZqfI9j2PsK1FcXgEzro5bGHcVKFCTqPLaNKZES8Evqv4LwvZARsq5qlg==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", - "pathe": "^2.0.2", + "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { @@ -8523,31 +8660,31 @@ } }, "node_modules/vitest": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.5.tgz", - "integrity": "sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.8.tgz", + "integrity": "sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.5", - "@vitest/mocker": "3.0.5", - "@vitest/pretty-format": "^3.0.5", - "@vitest/runner": "3.0.5", - "@vitest/snapshot": "3.0.5", - "@vitest/spy": "3.0.5", - "@vitest/utils": "3.0.5", - "chai": "^5.1.2", + "@vitest/expect": "3.0.8", + "@vitest/mocker": "3.0.8", + "@vitest/pretty-format": "^3.0.8", + "@vitest/runner": "3.0.8", + "@vitest/snapshot": "3.0.8", + "@vitest/spy": "3.0.8", + "@vitest/utils": "3.0.8", + "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", - "pathe": "^2.0.2", + "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.5", + "vite-node": "3.0.8", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8563,8 +8700,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.5", - "@vitest/ui": "3.0.5", + "@vitest/browser": "3.0.8", + "@vitest/ui": "3.0.8", "happy-dom": "*", "jsdom": "*" }, diff --git a/package.json b/package.json index d1d0cf963b7d9c4a9c3631d8d3daa1fe4fc21aec..496a026c223d167beabedbd1be8d5bde47756846 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "fs": "^0.0.1-security", "get-all-files": "^5.0.0", "i": "^0.3.7", + "jest-mock": "^29.7.0", "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", "mkdirp": "^3.0.1", @@ -40,6 +41,7 @@ "mysql": "^2.18.1", "mysql2": "^3.12.0", "node": "^18.20.6", + "nodemailer": "^6.10.0", "nodemon": "^3.1.9", "path-browserify": "^1.0.1", "react": "^18.3.1", @@ -76,6 +78,6 @@ "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5", - "vitest": "^3.0.5" + "vitest": "^3.0.8" } } diff --git a/src/App.tsx b/src/App.tsx index 8b6cae354b71c76bd6498789fb2f3337bf7f8e58..66f388f32bce4002ff5feea9fa45c7a0a3b280ac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,8 +14,10 @@ import ReactPlayer from "react-player"; // Library for embedding and playing vid import User from "./User"; import path from "path-browserify"; // Path library to work with file paths in the browser import Upload from "./upload.tsx"; +import VerifyEmail from "./VerifyEmail.tsx"; import axios from "axios"; import Terms from "./terms.tsx"; +import LikeButton from "./components/likeButton.tsx"; import TopBar from "./components/TopBar.tsx"; // import { createContext, useContext } from 'react'; // import VideoPlayer from './components/VideoPlayerUser.tsx'; @@ -150,14 +152,10 @@ function Home() { // Only fetch like data if there's a valid video if (currentVideo) { console.log("Video changed to:", currentVideo.split("/").pop()); - getLikeCount(); getViewCount(); // Only check if user has liked if they're logged in - if (loggedIn && userID) { - checkIfLiked(); - } } - }, [currentVideo, loggedIn, userID]); + }, [currentVideo]); // Switch to the next video in the array const handleNext = () => { @@ -279,106 +277,6 @@ function Home() { } assignUsername(); - async function getLikeCount() { - try { - const fileName = currentVideo.split("/").pop(); - if (!fileName) { - console.error("Error: fileName is missing."); - return; - } - - const response = await axios.get( - `${loginServer}/video-likes-by-filename/${fileName}` - ); - setLikeCount(response.data.likeCount); - } catch (error) { - console.error("Error fetching like count:", error); - setLikeCount(0); // Default to 0 if there's an error - } - } - - async function checkIfLiked() { - // console.log("Checking if liked for:", currentVideo.split("/").pop()); - - if (!loggedIn) { - // console.log("Not logged in, setting liked to false"); - setLiked(false); - return; - } - - const token = localStorage.getItem("authToken"); - if (!token) { - // console.log("No token, setting liked to false"); - setLiked(false); - return; - } - - const fileName = currentVideo.split("/").pop(); - if (!fileName) { - // No fileName, setting liked to false - setLiked(false); - return; - } - - try { - console.log("Making API request to check like status for:", fileName); - const response = await axios.get(`${loginServer}/check-like-status`, { - params: { - auth: token, - fileName: fileName, - }, - }); - - console.log("Like status response:", response.data); - setLiked(response.data.liked); - } catch (error) { - console.error("Error checking like status:", error); - setLiked(false); - } - } - - async function handleLike() { - if (!userID || !loggedIn) { - alert("You must be logged in to like videos."); - return; - } - - const fileName = currentVideo.split("/").pop(); - if (!fileName) { - console.error("Error: fileName is missing."); - return; - } - - const token = localStorage.getItem("authToken"); - if (!token) { - alert("Authentication error. Please log in again."); - setLoggedIn(false); - return; - } - - try { - const response = await axios.post( - `${loginServer}/like-video`, - { fileName: fileName }, // Send fileName in the request body - { - params: { auth: token }, // Send token as a query parameter - } - ); - - // Update UI based on the response message - if (response.data.message.includes("unliked")) { - setLiked(false); - setLikeCount((prev) => Math.max(0, prev - 1)); - } else { - setLiked(true); - setLikeCount((prev) => prev + 1); - } - } catch (error) { - console.error("Error liking/unliking video:", error); - alert("Failed to process like. Please try again."); - } - } - async function getViewCount() { try { const fileName = currentVideo.split("/").pop(); @@ -457,12 +355,16 @@ function Home() { height="60vh" onStart={handleVideoStart} /> - {/* 1. Video control buttons */} <div className="controls"> <div className="video-stats"> - <a onClick={handleLike} className={ liked ? "button liked" : "button not-liked" }> - <i className="fa-solid fa-heart"></i> {likeCount}<span className="desktop__text"> Likes</span> - </a> + <LikeButton + fileName={currentVideo ? currentVideo.split("/").pop() || "" : ""} + loggedIn={loggedIn} + userId={userID} + initialLikeCount={likeCount} + initialLiked={liked} + loginServer={loginServer} + /> <span className="views"> <i className="fa-solid fa-eye"></i> {viewCount}<span className="desktop__text"> Views</span> </span> @@ -558,7 +460,8 @@ function App() { <Route path="/signup" element={<Signup />} /> <Route path="/terms" element={<Terms />} /> <Route path="/reset-password" element={<ResetPassword />} /> - {/* User Page Route */} + <Route path="/verify-email" element={<VerifyEmail />} /> + {/* User Page Route */} {/* Protected Route for Dashboard and Video Player */} <Route element={<PrivateRoute />}> diff --git a/src/App.tsx.bkp b/src/App.tsx.bkp deleted file mode 100644 index d317bfcb777ff3510d2cf8a056c838bef378a601..0000000000000000000000000000000000000000 --- a/src/App.tsx.bkp +++ /dev/null @@ -1,107 +0,0 @@ -function Home() { - return ( - - <div className="app"> - {TopBar()} - <div className="app-container"> - <div className="video-container"> - <div className="video-player"> - <ReactPlayer - id="video" - url={currentVideo || ""} - playing={true} - muted={true} - controls={true} - loop={true} - playsinline={true} - width="80vw" - height="60vh" - onStart={handleVideoStart} - /> - </div> - <div className="video-stats"> - <button onClick={handleLike} style={{ color: liked ? "red" : "black" }}> - <i className="fa-solid fa-heart"></i> {likeCount} Likes - </button> - <span className="view-count"> - <i className="fa-solid fa-eye"></i> {viewCount} Views - </span> - </div> - - </div> - - {/* 1. Video control buttons */} - <div className="controls"> - {/* Download button */} - <a className="control-button" href={currentVideo} download> - <i className="fa-solid fa-download"></i> DOWNLOAD - </a> - - {/* 2. Navigate to User page */} - {/* <button className="control-button user-button" onClick={() => navigate('/user')}> - ENGAGER <i className="fa-solid fa-user"></i> - </button> */} - - {/*3. Next video button */} - <button className="control-button" onClick={handleNext}> - NEXT <i className="fa-solid fa-arrow-right"></i> - </button> - </div> - - {/*4. Upload button */} - <div className="upload-section"> - <button className="upload-button" onClick={() => navigate("/upload")}> - ENGAGE <i className="fa-solid fa-upload"></i> - </button> - </div> - - <div className="control-button" onClick={getVideoInfo}> - <i className="fas fa-info-circle"></i> VIDEO INFO - </div> - <div className="login-button-section"> - <button - className="control-button" - onClick={loggedIn ? () => navigate("/user") : handleBackToLogin} - > - {loggedIn ? ( - <> - <i className="fa-solid fa-user"></i> {username} - </> - ) : ( - <> - <i className="fa solid fa-right-to-bracket"></i> Log In - </> - )} - </button> - {/* <button className="control-button" onClick={async () => { - const userId = await getLoggedInUserId(); - if (userId !== null) { - const username = await getUsername(userId); - alert(username); - } else { - alert("User is not logged in."); - } - }}> - Engager <i className="fa-solid fa-user"></i> - </button> */} - {} - {/* <button className="control-button" onClick={handleBackToLogin}> - - Log In <i className="fa solid fa-right-to-bracket"></i> - </button> - <button className="control-button" onClick={async () => { - const userId = await getLoggedInUserId(); - if (userId !== null) { - const username = await getUsername(userId); - alert(username); - } else { - alert("User is not logged in."); - } - }}> - Engager <i className="fa-solid fa-user"></i> - </button> */} - </div> - </div> - </div> - ); -} \ No newline at end of file diff --git a/src/VerifyEmail.tsx b/src/VerifyEmail.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e838a0aade5f285676b248fcdde3e620afc07d20 --- /dev/null +++ b/src/VerifyEmail.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import axios from "axios"; + +let loginServer = "http://localhost:8081"; +if (import.meta.env.VITE_LOGIN_SERVER !== undefined) { + // console.log(import.meta.env.VITE_UPLOAD_SERVER); + loginServer = import.meta.env.VITE_LOGIN_SERVER; +} + +const VerifyEmail: React.FC = () => { + const [message, setMessage] = useState<string>("Verifying..."); + const navigate = useNavigate(); + const location = useLocation(); + + useEffect(() => { + const token = localStorage.getItem("authToken"); + + if (token) { + axios + .get(`${loginServer}/verify-email?token=${token}`) + .then((res) => { + setMessage(res.data.message); + setTimeout(() => navigate("/login"), 3000); // Redirect after 3 seconds + }) + .catch((err) => { + setMessage( + err.response?.data?.message || "Invalid or expired token." + ); + }); + } else { + setMessage("Invalid request."); + } + }, [location, navigate]); + + return ( + <div className="verify-email__container"> + <h2>Email Verification</h2> + <p>{message}</p> + </div> + ); +}; + +export default VerifyEmail; diff --git a/src/components/likeButton.tsx b/src/components/likeButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7bfc79d3012857724b437cca26200a363d46a9a6 --- /dev/null +++ b/src/components/likeButton.tsx @@ -0,0 +1,133 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import "../styles/App.scss"; +interface LikeButtonProps { + fileName: string; + loggedIn: boolean; + userId: number; + initialLikeCount?: number; + initialLiked?: boolean; + loginServer: string; +} + +const LikeButton: React.FC<LikeButtonProps> = ({ + fileName, + loggedIn, + userId, + initialLikeCount = 0, + initialLiked = false, + loginServer, +}) => { + const [likeCount, setLikeCount] = useState(initialLikeCount); + const [liked, setLiked] = useState(initialLiked); + + useEffect(() => { + getLikeCount(); + if (loggedIn && userId) { + checkIfLiked(); + } + }, [fileName, loggedIn, userId]); + + async function getLikeCount() { + try { + if (!fileName) { + console.error("Error: fileName is missing."); + return; + } + + const response = await axios.get( + `${loginServer}/video-likes-by-filename/${fileName}` + ); + setLikeCount(response.data.likeCount); + } catch (error) { + console.error("Error fetching like count:", error); + setLikeCount(initialLikeCount); + } + } + + async function checkIfLiked() { + if (!loggedIn) { + setLiked(false); + return; + } + + const token = localStorage.getItem("authToken"); + if (!token) { + setLiked(false); + return; + } + + if (!fileName) { + setLiked(false); + return; + } + + try { + const response = await axios.get(`${loginServer}/check-like-status`, { + params: { + auth: token, + fileName: fileName, + }, + }); + + setLiked(response.data.liked); + } catch (error) { + console.error("Error checking like status:", error); + setLiked(false); + } + } + + async function handleLike() { + if (!userId || !loggedIn) { + alert("You must be logged in to like videos."); + return; + } + + if (!fileName) { + console.error("Error: fileName is missing."); + return; + } + + const token = localStorage.getItem("authToken"); + if (!token) { + alert("Authentication error. Please log in again."); + return; + } + + try { + const response = await axios.post( + `${loginServer}/like-video`, + { fileName: fileName }, + { + params: { auth: token }, + } + ); + + if (response.data.message.includes("unliked")) { + setLiked(false); + setLikeCount((prev) => Math.max(0, prev - 1)); + } else { + setLiked(true); + setLikeCount((prev) => prev + 1); + } + } catch (error) { + console.error("Error liking/unliking video:", error); + alert("Failed to process like. Please try again."); + } + } + + return ( + <a onClick={handleLike} className={ liked ? "button liked" : "button not-liked" }> + <i className="fa-solid fa-heart"></i> {likeCount}<span className="desktop__text"> Likes</span> + </a> + // <button + // onClick={handleLike} + // style={{ color: liked ? "red" : "black" }} + // data-testid="like-button" + // > + // <i className="fa-solid fa-heart"></i> {likeCount} Likes + // </button> + ); +}; + +export default LikeButton; diff --git a/src/signupValidation.tsx b/src/components/signupValidation.tsx similarity index 100% rename from src/signupValidation.tsx rename to src/components/signupValidation.tsx diff --git a/src/login.tsx b/src/login.tsx index 03049d779ab9d5e4126519525698395439aec695..f9c95ee0971a0a320eff30bfc6d025e8c889456a 100644 --- a/src/login.tsx +++ b/src/login.tsx @@ -92,6 +92,9 @@ const Login: React.FC = () => { } else if (error.response && error.response.status === 401) { // Invalid password setErrors({ password: "Incorrect password! Please try again!" }); + } else if (error.response && error.response.status === 403) { + // Forbidden error + setErrors({ password: "Account is not verified! Please check your email." }); } else { // General error setErrors({ password: "An error occurred during login" }); diff --git a/src/signup.tsx b/src/signup.tsx index a2845be35b7617a6e760304a0db9702d67af23d2..497b78c9e83b9ee8980b71040ee865f92b4b480c 100644 --- a/src/signup.tsx +++ b/src/signup.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import "./styles/auth.scss"; -import validation from "./signupValidation"; +import validation from "./components/signupValidation"; import axios from "axios"; // let uploadServer = "http://localhost:3001"; @@ -51,10 +51,12 @@ const Signup: React.FC = () => { axios .post(`${loginServer}/signup`, formValues) .then(() => { - setSuccessMessage("You have successfully signed up! Redirecting..."); + setSuccessMessage( + "You have successfully signed up! Please verify your email." + ); setTimeout(() => { - navigate("/login"); // Redirect after 1.5 seconds - }, 1500); + navigate("/login"); // Redirect after 3 seconds + }, 3000); setName(""); setEmail(""); setPassword(""); diff --git a/tests/like.test.tsx b/tests/like.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1cb118206f50d053adf51cbec7a76bbd4d8d97dd --- /dev/null +++ b/tests/like.test.tsx @@ -0,0 +1,152 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import "@testing-library/jest-dom"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import axios from "axios"; +import LikeButton from "../src/likeButton"; + +// Mock axios +vi.mock("axios"); + +describe("Like Functionality", () => { + const originalEnv = process.env; + const mockLoginServer = "http://test-login-server"; + + beforeEach(() => { + // Mock environment variables + vi.stubEnv("VITE_UPLOAD_SERVER", "http://test-upload-server"); + vi.stubEnv("VITE_LOGIN_SERVER", mockLoginServer); + + // Mock localStorage + Object.defineProperty(window, "localStorage", { + value: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }, + writable: true, + }); + + // Mock localStorage for logged-in user + vi.spyOn(window.localStorage, "getItem").mockImplementation((key) => { + if (key === "authToken") return "mock-token"; + return null; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + process.env = originalEnv; + }); + // Test 1: Test case for like functionality for logged-in user + it("should call like-video API when like button is clicked by logged-in user", async () => { + // Mock responses for API calls + vi.mocked(axios.get).mockImplementation((url) => { + if (url.includes("/video-likes-by-filename/")) { + return Promise.resolve({ data: { likeCount: 5 } }); + } else if (url.includes("/check-like-status")) { + return Promise.resolve({ data: { liked: false } }); + } + return Promise.resolve({ data: {} }); + }); + + vi.mocked(axios.post).mockImplementation((url) => { + if (url.includes("/like-video")) { + return Promise.resolve({ + data: { message: "Video liked successfully" }, + }); + } + return Promise.resolve({ data: {} }); + }); + + // Render just the LikeButton component (avoiding the whole App with router) + render( + <LikeButton + fileName="video1.mp4" + loggedIn={true} + userId={123} + initialLikeCount={5} + loginServer={mockLoginServer} + /> + ); + + // Find and click the like button + const likeButton = screen.getByTestId("like-button"); + fireEvent.click(likeButton); + + // Verify that the like API was called + await waitFor(() => { + expect(axios.post).toHaveBeenCalledWith( + `${mockLoginServer}/like-video`, + { fileName: "video1.mp4" }, + { params: { auth: "mock-token" } } + ); + }); + }); + // Test 2: Test case for like functionality for logged-out user + it("should show alert if user is not logged in", async () => { + // Mock alert + const alertMock = vi.spyOn(window, "alert").mockImplementation(() => {}); + + render( + <LikeButton + fileName="video1.mp4" + loggedIn={false} + userId={0} + initialLikeCount={5} + loginServer={mockLoginServer} + /> + ); + + // Find and click the like button + const likeButton = screen.getByTestId("like-button"); + fireEvent.click(likeButton); + + // Verify alert was shown + expect(alertMock).toHaveBeenCalledWith( + "You must be logged in to like videos." + ); + }); + + // Test 3: Test case for updating UI after liking a video + it("should update UI after liking a video", async () => { + // Mock responses + vi.mocked(axios.get).mockImplementation((url) => { + if (url.includes("/video-likes-by-filename/")) { + return Promise.resolve({ data: { likeCount: 5 } }); + } else if (url.includes("/check-like-status")) { + return Promise.resolve({ data: { liked: false } }); + } + return Promise.resolve({ data: {} }); + }); + + vi.mocked(axios.post).mockImplementation((url) => { + if (url.includes("/like-video")) { + return Promise.resolve({ + data: { message: "Video liked successfully" }, + }); + } + return Promise.resolve({ data: {} }); + }); + + render( + <LikeButton + fileName="video1.mp4" + loggedIn={true} + userId={123} + initialLikeCount={5} + loginServer={mockLoginServer} + /> + ); + + // Find and click the like button + const likeButton = screen.getByTestId("like-button"); + fireEvent.click(likeButton); + + // Verify that the UI updates + await waitFor(() => { + expect(likeButton).toHaveStyle("color: rgb(255, 0, 0)"); + expect(likeButton).toHaveTextContent("6 Likes"); + }); + }); +});