diff --git a/backend/middleware/alphaAccess.js b/backend/middleware/alphaAccess.js new file mode 100644 index 0000000..eca546a --- /dev/null +++ b/backend/middleware/alphaAccess.js @@ -0,0 +1,59 @@ +const { AlphaInvitation } = require("../models"); +const logger = require("../utils/logger"); + +/** + * Middleware to require alpha access for protected routes + * Checks for valid alpha cookie or registered user with invitation + */ +const requireAlphaAccess = async (req, res, next) => { + try { + let hasAccess = false; + + // Check 1: Valid alpha access cookie + if (req.cookies && req.cookies.alphaAccessCode) { + const { code } = req.cookies.alphaAccessCode; + if (code) { + const invitation = await AlphaInvitation.findOne({ + where: { code, status: ["pending", "active"] }, + }); + if (invitation) { + hasAccess = true; + } + } + } + + // Check 2: Authenticated user who has used an invitation + if (!hasAccess && req.user && req.user.id) { + const invitation = await AlphaInvitation.findOne({ + where: { usedBy: req.user.id }, + }); + if (invitation) { + hasAccess = true; + } + } + + if (!hasAccess) { + logger.warn( + `Alpha access denied for request to ${req.path}`, + { + ip: req.ip, + userId: req.user?.id, + } + ); + return res.status(403).json({ + error: "Alpha access required", + code: "ALPHA_ACCESS_REQUIRED", + }); + } + + // Access granted + next(); + } catch (error) { + logger.error(`Error checking alpha access: ${error.message}`, { error }); + res.status(500).json({ + error: "Server error", + }); + } +}; + +module.exports = { requireAlphaAccess }; diff --git a/backend/middleware/rateLimiter.js b/backend/middleware/rateLimiter.js index 60bc870..3533274 100644 --- a/backend/middleware/rateLimiter.js +++ b/backend/middleware/rateLimiter.js @@ -143,6 +143,18 @@ const authRateLimiters = { legacyHeaders: false, }), + // Alpha code validation rate limiter + alphaCodeValidation: rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // 5 code validation attempts per 15 minutes + message: { + error: "Too many attempts. Please try again later.", + retryAfter: 900, + }, + standardHeaders: true, + legacyHeaders: false, + }), + // General API rate limiter general: rateLimit({ windowMs: 60 * 1000, // 1 minute @@ -166,6 +178,7 @@ module.exports = { loginLimiter: authRateLimiters.login, registerLimiter: authRateLimiters.register, passwordResetLimiter: authRateLimiters.passwordReset, + alphaCodeValidationLimiter: authRateLimiters.alphaCodeValidation, generalLimiter: authRateLimiters.general, // Burst protection diff --git a/backend/models/AlphaInvitation.js b/backend/models/AlphaInvitation.js new file mode 100644 index 0000000..0e2493c --- /dev/null +++ b/backend/models/AlphaInvitation.js @@ -0,0 +1,69 @@ +const { DataTypes } = require("sequelize"); +const sequelize = require("../config/database"); + +const AlphaInvitation = sequelize.define( + "AlphaInvitation", + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + code: { + type: DataTypes.STRING, + unique: true, + allowNull: false, + validate: { + is: /^ALPHA-[A-Z0-9]{8}$/i, + }, + }, + email: { + type: DataTypes.STRING, + unique: true, + allowNull: false, + validate: { + isEmail: true, + }, + set(value) { + // Normalize email to lowercase + this.setDataValue("email", value.toLowerCase().trim()); + }, + }, + usedBy: { + type: DataTypes.UUID, + allowNull: true, + references: { + model: "Users", + key: "id", + }, + }, + usedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + status: { + type: DataTypes.ENUM("pending", "active", "revoked"), + defaultValue: "pending", + allowNull: false, + }, + notes: { + type: DataTypes.TEXT, + allowNull: true, + }, + }, + { + indexes: [ + { + fields: ["code"], + }, + { + fields: ["email"], + }, + { + fields: ["status"], + }, + ], + } +); + +module.exports = AlphaInvitation; diff --git a/backend/models/index.js b/backend/models/index.js index 5fa46a3..356b7da 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -7,6 +7,7 @@ const ItemRequest = require("./ItemRequest"); const ItemRequestResponse = require("./ItemRequestResponse"); const UserAddress = require("./UserAddress"); const ConditionCheck = require("./ConditionCheck"); +const AlphaInvitation = require("./AlphaInvitation"); User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" }); Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" }); @@ -71,6 +72,16 @@ ConditionCheck.belongsTo(User, { foreignKey: "submittedBy", }); +// AlphaInvitation associations +AlphaInvitation.belongsTo(User, { + as: "user", + foreignKey: "usedBy", +}); +User.hasMany(AlphaInvitation, { + as: "alphaInvitations", + foreignKey: "usedBy", +}); + module.exports = { sequelize, User, @@ -81,4 +92,5 @@ module.exports = { ItemRequestResponse, UserAddress, ConditionCheck, + AlphaInvitation, }; diff --git a/backend/package.json b/backend/package.json index 6d6a25b..4ccbe9d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,7 +15,14 @@ "test:coverage": "jest --coverage --forceExit --maxWorkers=4", "test:unit": "NODE_ENV=test jest tests/unit", "test:integration": "NODE_ENV=test jest tests/integration", - "test:ci": "NODE_ENV=test jest --ci --coverage --maxWorkers=2" + "test:ci": "NODE_ENV=test jest --ci --coverage --maxWorkers=2", + "alpha:add": "NODE_ENV=dev node scripts/manageAlphaInvitations.js add", + "alpha:list": "NODE_ENV=dev node scripts/manageAlphaInvitations.js list", + "alpha:revoke": "NODE_ENV=dev node scripts/manageAlphaInvitations.js revoke", + "alpha:restore": "NODE_ENV=dev node scripts/manageAlphaInvitations.js restore", + "alpha:resend": "NODE_ENV=dev node scripts/manageAlphaInvitations.js resend", + "alpha:bulk": "NODE_ENV=dev node scripts/manageAlphaInvitations.js bulk", + "alpha:help": "node scripts/manageAlphaInvitations.js help" }, "keywords": [], "author": "", diff --git a/backend/routes/alpha.js b/backend/routes/alpha.js new file mode 100644 index 0000000..20ccce9 --- /dev/null +++ b/backend/routes/alpha.js @@ -0,0 +1,127 @@ +const express = require("express"); +const { AlphaInvitation, User } = require("../models"); +const { authenticateToken, optionalAuth } = require("../middleware/auth"); +const { alphaCodeValidationLimiter } = require("../middleware/rateLimiter"); +const logger = require("../utils/logger"); +const crypto = require("crypto"); +const router = express.Router(); + +// Helper function to check if user has alpha access +async function checkAlphaAccess(req) { + // Check 1: Valid alpha access cookie + if (req.cookies && req.cookies.alphaAccessCode) { + const { code } = req.cookies.alphaAccessCode; + const invitation = await AlphaInvitation.findOne({ + where: { code, status: ["pending", "active"] }, + }); + if (invitation) { + return true; + } + } + + // Check 2: Authenticated user who has used an invitation + if (req.user && req.user.id) { + const invitation = await AlphaInvitation.findOne({ + where: { usedBy: req.user.id }, + }); + if (invitation) { + return true; + } + } + + return false; +} + +/** + * POST /api/alpha/validate-code + * Validates an alpha invitation code and grants access + */ +router.post("/validate-code", alphaCodeValidationLimiter, async (req, res) => { + try { + const { code } = req.body; + + if (!code) { + return res.status(400).json({ + error: "Code is required", + }); + } + + // Normalize code (uppercase, trim) + const normalizedCode = code.trim().toUpperCase(); + + // Validate code format + if (!/^ALPHA-[A-Z0-9]{8}$/.test(normalizedCode)) { + logger.warn(`Invalid code format attempted: ${code}`); + return res.status(400).json({ + error: "Invalid alpha code", + }); + } + + // Find invitation in database + const invitation = await AlphaInvitation.findOne({ + where: { code: normalizedCode }, + }); + + // Generic error for invalid code (prevent enumeration) + if (!invitation) { + logger.warn(`Code not found: ${normalizedCode}`); + return res.status(400).json({ + error: "Invalid alpha code", + }); + } + + // Check if code is revoked + if (invitation.status === "revoked") { + logger.warn(`Revoked code attempted: ${normalizedCode}`); + return res.status(400).json({ + error: "Invalid alpha code", + }); + } + + // Set httpOnly cookie for alpha access + const cookieData = { + code: normalizedCode, + validatedAt: new Date().toISOString(), + }; + + res.cookie("alphaAccessCode", cookieData, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "strict", + maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days + }); + + logger.info(`Alpha code validated successfully: ${normalizedCode}`); + + res.json({ + success: true, + message: "Access granted", + }); + } catch (error) { + logger.error(`Error validating alpha code: ${error.message}`, { error }); + res.status(500).json({ + error: "Server error", + }); + } +}); + +/** + * GET /api/alpha/verify-session + * Checks if current session has alpha access + */ +router.get("/verify-session", optionalAuth, async (req, res) => { + try { + const hasAccess = await checkAlphaAccess(req); + + res.json({ + hasAccess, + }); + } catch (error) { + logger.error(`Error verifying alpha session: ${error.message}`, { error }); + res.status(500).json({ + error: "Server error", + }); + } +}); + +module.exports = { router, checkAlphaAccess }; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 2a14f1e..8322124 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -1,7 +1,7 @@ const express = require("express"); const jwt = require("jsonwebtoken"); const { OAuth2Client } = require("google-auth-library"); -const { User } = require("../models"); // Import from models/index.js to get models with associations +const { User, AlphaInvitation } = require("../models"); // Import from models/index.js to get models with associations const logger = require("../utils/logger"); const emailService = require("../services/emailService"); const crypto = require("crypto"); @@ -64,6 +64,35 @@ router.post( }); } + // Alpha access validation + let alphaInvitation = null; + if (req.signedCookies && req.signedCookies.alphaAccessCode) { + const { code } = req.signedCookies.alphaAccessCode; + if (code) { + alphaInvitation = await AlphaInvitation.findOne({ + where: { code }, + }); + + if (!alphaInvitation) { + return res.status(403).json({ + error: "Invalid alpha access code", + }); + } + + if (alphaInvitation.status === "revoked") { + return res.status(403).json({ + error: "This alpha access code is no longer valid", + }); + } + } + } + + if (!alphaInvitation) { + return res.status(403).json({ + error: "Alpha access required. Please enter your invitation code first.", + }); + } + const user = await User.create({ username, email, @@ -73,6 +102,13 @@ router.post( phone, }); + // Link alpha invitation to user + await alphaInvitation.update({ + usedBy: user.id, + usedAt: new Date(), + status: "active", + }); + // Generate verification token and send email await user.generateVerificationToken(); @@ -318,6 +354,20 @@ router.post( isVerified: true, verifiedAt: new Date(), }); + + // Check if there's an alpha invitation for this email + const alphaInvitation = await AlphaInvitation.findOne({ + where: { email: email.toLowerCase().trim() }, + }); + + if (alphaInvitation && !alphaInvitation.usedBy) { + // Link invitation to new user + await alphaInvitation.update({ + usedBy: user.id, + usedAt: new Date(), + status: "active", + }); + } } // Generate JWT tokens diff --git a/backend/scripts/manageAlphaInvitations.js b/backend/scripts/manageAlphaInvitations.js new file mode 100644 index 0000000..350ec74 --- /dev/null +++ b/backend/scripts/manageAlphaInvitations.js @@ -0,0 +1,480 @@ +// Load environment config +const env = process.env.NODE_ENV || "dev"; +const envFile = `.env.${env}`; +require("dotenv").config({ path: envFile }); + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const { AlphaInvitation, User, sequelize } = require("../models"); +const emailService = require("../services/emailService"); +const logger = require("../utils/logger"); + +// Generate unique alpha code +async function generateUniqueAlphaCode() { + let code; + let exists = true; + + while (exists) { + // Generate exactly 8 random alphanumeric characters + const chars = "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789"; + let randomStr = ""; + + for (let i = 0; i < 8; i++) { + const randomIndex = crypto.randomInt(0, chars.length); + randomStr += chars[randomIndex]; + } + + code = `ALPHA-${randomStr}`; + + // Check if code exists + const existing = await AlphaInvitation.findOne({ where: { code } }); + exists = !!existing; + } + + return code; +} + +// Normalize email +function normalizeEmail(email) { + return email.toLowerCase().trim(); +} + +// Add invitation +async function addInvitation(email, notes = "") { + try { + email = normalizeEmail(email); + + // Check if invitation already exists for this email + const existing = await AlphaInvitation.findOne({ where: { email } }); + if (existing) { + console.log(`\n❌ Invitation already exists for ${email}`); + console.log(` Code: ${existing.code}`); + console.log(` Status: ${existing.status}`); + console.log(` Created: ${existing.createdAt}`); + return null; + } + + // Generate unique code + const code = await generateUniqueAlphaCode(); + + // Create invitation + const invitation = await AlphaInvitation.create({ + code, + email, + status: "pending", + notes, + }); + + // Send invitation email + let emailSent = false; + try { + await emailService.sendAlphaInvitation(email, code); + emailSent = true; + } catch (emailError) { + console.log(`\n⚠️ Warning: Failed to send email to ${email}`); + console.log(` Error: ${emailError.message}`); + console.log(` Invitation created but email not sent.`); + } + + console.log(`\n✅ Alpha invitation created successfully!`); + console.log(` Email: ${email}`); + console.log(` Code: ${code}`); + console.log(` Email sent: ${emailSent ? "Yes" : "No"}`); + if (notes) { + console.log(` Notes: ${notes}`); + } + + return invitation; + } catch (error) { + console.error(`\n❌ Error creating invitation: ${error.message}`); + throw error; + } +} + +// Resend invitation +async function resendInvitation(emailOrCode) { + try { + const input = emailOrCode.trim(); + let invitation; + + // Try to find by code first (if it looks like a code), otherwise by email + if (input.toUpperCase().startsWith("ALPHA-")) { + invitation = await AlphaInvitation.findOne({ + where: { code: input.toUpperCase() } + }); + } else { + invitation = await AlphaInvitation.findOne({ + where: { email: normalizeEmail(input) } + }); + } + + if (!invitation) { + console.log(`\n❌ Invitation not found: ${input}`); + return null; + } + + // Check if revoked + if (invitation.status === "revoked") { + console.log(`\n❌ Cannot resend revoked invitation`); + console.log(` Code: ${invitation.code}`); + console.log(` Email: ${invitation.email}`); + return null; + } + + // Warn if already used + if (invitation.usedBy) { + console.log(`\n⚠️ Warning: This invitation has already been used`); + console.log(` Used by user ID: ${invitation.usedBy}`); + console.log(` Continuing with resend...\n`); + } + + // Resend the email + try { + await emailService.sendAlphaInvitation(invitation.email, invitation.code); + + console.log(`\n✅ Alpha invitation resent successfully!`); + console.log(` Email: ${invitation.email}`); + console.log(` Code: ${invitation.code}`); + console.log(` Status: ${invitation.status}`); + + return invitation; + } catch (emailError) { + console.error(`\n❌ Error sending email: ${emailError.message}`); + throw emailError; + } + } catch (error) { + console.error(`\n❌ Error resending invitation: ${error.message}`); + throw error; + } +} + +// List invitations +async function listInvitations(filter = "all") { + try { + let where = {}; + + if (filter === "pending") { + where.status = "pending"; + where.usedBy = null; + } else if (filter === "active") { + where.status = "active"; + } else if (filter === "revoked") { + where.status = "revoked"; + } else if (filter === "unused") { + where.usedBy = null; + } + + const invitations = await AlphaInvitation.findAll({ + where, + include: [ + { + model: User, + as: "user", + attributes: ["id", "email", "firstName", "lastName"], + }, + ], + order: [["createdAt", "DESC"]], + }); + + console.log( + `\n📋 Alpha Invitations (${invitations.length} total, filter: ${filter})\n` + ); + console.log("─".repeat(100)); + console.log( + "CODE".padEnd(15) + + "EMAIL".padEnd(30) + + "STATUS".padEnd(10) + + "USED BY".padEnd(25) + + "CREATED" + ); + console.log("─".repeat(100)); + + if (invitations.length === 0) { + console.log("No invitations found."); + } else { + invitations.forEach((inv) => { + const usedBy = inv.user + ? `${inv.user.firstName} ${inv.user.lastName}` + : "-"; + const created = new Date(inv.createdAt).toLocaleDateString(); + + console.log( + inv.code.padEnd(15) + + inv.email.padEnd(30) + + inv.status.padEnd(10) + + usedBy.padEnd(25) + + created + ); + }); + } + console.log("─".repeat(100)); + + // Summary + const stats = { + total: invitations.length, + pending: invitations.filter((i) => i.status === "pending" && !i.usedBy) + .length, + active: invitations.filter((i) => i.status === "active" && i.usedBy) + .length, + revoked: invitations.filter((i) => i.status === "revoked").length, + }; + + console.log( + `\nSummary: ${stats.pending} pending | ${stats.active} active | ${stats.revoked} revoked\n` + ); + + return invitations; + } catch (error) { + console.error(`\n❌ Error listing invitations: ${error.message}`); + throw error; + } +} + +// Revoke invitation +async function revokeInvitation(code) { + try { + code = code.trim().toUpperCase(); + + const invitation = await AlphaInvitation.findOne({ where: { code } }); + + if (!invitation) { + console.log(`\n❌ Invitation not found with code: ${code}`); + return null; + } + + if (invitation.status === "revoked") { + console.log(`\n⚠️ Invitation is already revoked: ${code}`); + return invitation; + } + + await invitation.update({ status: "revoked" }); + + console.log(`\n✅ Invitation revoked successfully!`); + console.log(` Code: ${code}`); + console.log(` Email: ${invitation.email}`); + + return invitation; + } catch (error) { + console.error(`\n❌ Error revoking invitation: ${error.message}`); + throw error; + } +} + +// Restore invitation +async function restoreInvitation(code) { + try { + code = code.trim().toUpperCase(); + + const invitation = await AlphaInvitation.findOne({ where: { code } }); + + if (!invitation) { + console.log(`\n❌ Invitation not found with code: ${code}`); + return null; + } + + if (invitation.status !== "revoked") { + console.log(`\n⚠️ Invitation is not revoked (current status: ${invitation.status})`); + console.log(` Code: ${code}`); + console.log(` Email: ${invitation.email}`); + return invitation; + } + + // Determine the appropriate status to restore to + const newStatus = invitation.usedBy ? "active" : "pending"; + + await invitation.update({ status: newStatus }); + + console.log(`\n✅ Invitation restored successfully!`); + console.log(` Code: ${code}`); + console.log(` Email: ${invitation.email}`); + console.log(` Status: ${newStatus} (${invitation.usedBy ? 'was previously used' : 'never used'})`); + + return invitation; + } catch (error) { + console.error(`\n❌ Error restoring invitation: ${error.message}`); + throw error; + } +} + +// Bulk import from CSV +async function bulkImport(csvPath) { + try { + if (!fs.existsSync(csvPath)) { + console.log(`\n❌ File not found: ${csvPath}`); + return; + } + + const csvContent = fs.readFileSync(csvPath, "utf-8"); + const lines = csvContent.split("\n").filter((line) => line.trim()); + + // Skip header if present + const hasHeader = lines[0].toLowerCase().includes("email"); + const dataLines = hasHeader ? lines.slice(1) : lines; + + console.log( + `\n📥 Importing ${dataLines.length} invitations from ${csvPath}...\n` + ); + + let successCount = 0; + let failCount = 0; + + for (const line of dataLines) { + const [email, notes] = line.split(",").map((s) => s.trim()); + + if (!email) { + console.log(`⚠️ Skipping empty line`); + failCount++; + continue; + } + + try { + await addInvitation(email, notes || ""); + successCount++; + } catch (error) { + console.log(`❌ Failed to add ${email}: ${error.message}`); + failCount++; + } + } + + console.log(`\n✅ Bulk import completed!`); + console.log(` Success: ${successCount}`); + console.log(` Failed: ${failCount}`); + console.log(` Total: ${dataLines.length}\n`); + } catch (error) { + console.error(`\n❌ Error during bulk import: ${error.message}`); + throw error; + } +} + +// Main CLI handler +async function main() { + const args = process.argv.slice(2); + const command = args[0]; + + try { + // Sync database + await sequelize.sync(); + + if (!command || command === "help") { + console.log(` +Alpha Invitation Management CLI + +Usage: + node scripts/manageAlphaInvitations.js [options] + +Commands: + add [notes] Add a new alpha invitation + list [filter] List all invitations (filter: all|pending|active|revoked|unused) + revoke Revoke an invitation code + restore Restore a revoked invitation + resend Resend an invitation email + bulk Bulk import invitations from CSV file + +Examples: + node scripts/manageAlphaInvitations.js add alice@example.com "Product team" + node scripts/manageAlphaInvitations.js list pending + node scripts/manageAlphaInvitations.js revoke ALPHA-ABC12345 + node scripts/manageAlphaInvitations.js restore ALPHA-ABC12345 + node scripts/manageAlphaInvitations.js resend alice@example.com + node scripts/manageAlphaInvitations.js bulk invitations.csv + +CSV Format: + email,notes + alice@example.com,Product team + bob@example.com,Early adopter + `); + } else if (command === "add") { + const email = args[1]; + const notes = args.slice(2).join(" "); + + if (!email) { + console.log("\n❌ Error: Email is required"); + console.log( + "Usage: node scripts/manageAlphaInvitations.js add [notes]\n" + ); + process.exit(1); + } + + await addInvitation(email, notes); + } else if (command === "list") { + const filter = args[1] || "all"; + await listInvitations(filter); + } else if (command === "revoke") { + const code = args[1]; + + if (!code) { + console.log("\n❌ Error: Code is required"); + console.log( + "Usage: node scripts/manageAlphaInvitations.js revoke \n" + ); + process.exit(1); + } + + await revokeInvitation(code); + } else if (command === "resend") { + const emailOrCode = args[1]; + + if (!emailOrCode) { + console.log("\n❌ Error: Email or code is required"); + console.log( + "Usage: node scripts/manageAlphaInvitations.js resend \n" + ); + process.exit(1); + } + + await resendInvitation(emailOrCode); + } else if (command === "restore") { + const code = args[1]; + + if (!code) { + console.log("\n❌ Error: Code is required"); + console.log( + "Usage: node scripts/manageAlphaInvitations.js restore \n" + ); + process.exit(1); + } + + await restoreInvitation(code); + } else if (command === "bulk") { + const csvPath = args[1]; + + if (!csvPath) { + console.log("\n❌ Error: CSV path is required"); + console.log( + "Usage: node scripts/manageAlphaInvitations.js bulk \n" + ); + process.exit(1); + } + + await bulkImport(path.resolve(csvPath)); + } else { + console.log(`\n❌ Unknown command: ${command}`); + console.log( + "Run 'node scripts/manageAlphaInvitations.js help' for usage information\n" + ); + process.exit(1); + } + + process.exit(0); + } catch (error) { + console.error(`\n❌ Fatal error: ${error.message}`); + console.error(error.stack); + process.exit(1); + } +} + +// Run if called directly +if (require.main === module) { + main(); +} + +module.exports = { + addInvitation, + listInvitations, + revokeInvitation, + restoreInvitation, + resendInvitation, + bulkImport, + generateUniqueAlphaCode, +}; diff --git a/backend/server.js b/backend/server.js index e19d5e7..a830d66 100644 --- a/backend/server.js +++ b/backend/server.js @@ -16,6 +16,7 @@ const logger = require("./utils/logger"); const morgan = require("morgan"); const authRoutes = require("./routes/auth"); +const { router: alphaRoutes } = require("./routes/alpha"); const userRoutes = require("./routes/users"); const itemRoutes = require("./routes/items"); const rentalRoutes = require("./routes/rentals"); @@ -41,6 +42,7 @@ const { const { generalLimiter } = require("./middleware/rateLimiter"); const errorLogger = require("./middleware/errorLogger"); const apiLogger = require("./middleware/apiLogger"); +const { requireAlphaAccess } = require("./middleware/alphaAccess"); // Apply security middleware app.use(enforceHTTPS); @@ -106,20 +108,25 @@ app.use( // Serve static files from uploads directory app.use("/uploads", express.static(path.join(__dirname, "uploads"))); -app.use("/api/auth", authRoutes); -app.use("/api/users", userRoutes); -app.use("/api/items", itemRoutes); -app.use("/api/rentals", rentalRoutes); -app.use("/api/messages", messageRoutes); -app.use("/api/item-requests", itemRequestRoutes); -app.use("/api/stripe", stripeRoutes); -app.use("/api/maps", mapsRoutes); -app.use("/api/condition-checks", conditionCheckRoutes); +// Public routes (no alpha access required) +app.use("/api/alpha", alphaRoutes); +app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration +// Health check endpoint app.get("/", (req, res) => { res.json({ message: "CommunityRentals.App API is running!" }); }); +// Protected routes (require alpha access) +app.use("/api/users", requireAlphaAccess, userRoutes); +app.use("/api/items", requireAlphaAccess, itemRoutes); +app.use("/api/rentals", requireAlphaAccess, rentalRoutes); +app.use("/api/messages", requireAlphaAccess, messageRoutes); +app.use("/api/item-requests", requireAlphaAccess, itemRequestRoutes); +app.use("/api/stripe", requireAlphaAccess, stripeRoutes); +app.use("/api/maps", requireAlphaAccess, mapsRoutes); +app.use("/api/condition-checks", requireAlphaAccess, conditionCheckRoutes); + // Error handling middleware (must be last) app.use(errorLogger); app.use(sanitizeError); diff --git a/backend/services/emailService.js b/backend/services/emailService.js index 7c10a2e..efbd765 100644 --- a/backend/services/emailService.js +++ b/backend/services/emailService.js @@ -51,6 +51,7 @@ class EmailService { "rentalCompletionCongratsToOwner.html", "payoutReceivedToOwner.html", "firstListingCelebrationToOwner.html", + "alphaInvitationToUser.html", ]; for (const templateFile of templateFiles) { @@ -594,6 +595,31 @@ class EmailService { ); } + async sendAlphaInvitation(email, code) { + // Ensure service is initialized before rendering template + if (!this.initialized) { + await this.initialize(); + } + + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + + const variables = { + code: code, + email: email, + frontendUrl: frontendUrl, + title: "Welcome to Alpha Testing!", + message: `You've been invited to join our exclusive alpha testing program. Use the code ${code} to unlock access and be among the first to experience our platform.`, + }; + + const htmlContent = this.renderTemplate("alphaInvitationToUser", variables); + + return await this.sendEmail( + email, + "Your Alpha Access Code - RentAll", + htmlContent + ); + } + async sendPasswordResetEmail(user, resetToken) { const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; const resetUrl = `${frontendUrl}/reset-password?token=${resetToken}`; diff --git a/backend/templates/emails/alphaInvitationToUser.html b/backend/templates/emails/alphaInvitationToUser.html new file mode 100644 index 0000000..42d6e35 --- /dev/null +++ b/backend/templates/emails/alphaInvitationToUser.html @@ -0,0 +1,283 @@ + + + + + + + Your Alpha Access Code - RentAll + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0392e7a..6c2fd22 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import Navbar from './components/Navbar'; import Footer from './components/Footer'; import AuthModal from './components/AuthModal'; +import AlphaGate from './components/AlphaGate'; import Home from './pages/Home'; import GoogleCallback from './pages/GoogleCallback'; import VerifyEmail from './pages/VerifyEmail'; @@ -25,10 +26,49 @@ import CreateItemRequest from './pages/CreateItemRequest'; import MyRequests from './pages/MyRequests'; import EarningsDashboard from './pages/EarningsDashboard'; import PrivateRoute from './components/PrivateRoute'; +import axios from 'axios'; import './App.css'; +const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5001'; + const AppContent: React.FC = () => { const { showAuthModal, authModalMode, closeAuthModal } = useAuth(); + const [hasAlphaAccess, setHasAlphaAccess] = useState(null); + const [checkingAccess, setCheckingAccess] = useState(true); + + useEffect(() => { + const checkAlphaAccess = async () => { + try { + const response = await axios.get(`${API_URL}/alpha/verify-session`, { + withCredentials: true, + }); + setHasAlphaAccess(response.data.hasAccess); + } catch (error) { + console.error('Error checking alpha access:', error); + setHasAlphaAccess(false); + } finally { + setCheckingAccess(false); + } + }; + + checkAlphaAccess(); + }, []); + + // Show loading state while checking + if (checkingAccess) { + return ( +
+
+ Loading... +
+
+ ); + } + + // Show alpha gate if no access + if (!hasAlphaAccess) { + return ; + } return ( <> diff --git a/frontend/src/components/AlphaGate.tsx b/frontend/src/components/AlphaGate.tsx new file mode 100644 index 0000000..438f3e9 --- /dev/null +++ b/frontend/src/components/AlphaGate.tsx @@ -0,0 +1,215 @@ +import React, { useState } from "react"; +import axios from "axios"; + +const API_URL = process.env.REACT_APP_API_URL || "http://localhost:5001"; + +const AlphaGate: React.FC = () => { + const [code, setCode] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const handleCodeChange = (e: React.ChangeEvent) => { + const value = e.target.value.toUpperCase(); + // Only allow alphanumeric, max 8 characters + if (value.length <= 8 && /^[A-Z0-9]*$/.test(value)) { + setCode(value); + } + setError(""); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (code.length < 8) { + setError("Please enter a complete alpha access code"); + return; + } + + setLoading(true); + + const fullCode = `ALPHA-${code}`; + + try { + const response = await axios.post( + `${API_URL}/alpha/validate-code`, + { code: fullCode }, + { withCredentials: true } + ); + + if (response.data.success) { + // Store alpha verification in localStorage as backup + localStorage.setItem("alphaVerified", "true"); + // Reload the page to trigger access check + window.location.reload(); + } + } catch (err: any) { + if (err.response?.status === 429) { + setError("Too many attempts. Please try again in a few minutes."); + } else if (err.response?.data?.error) { + setError(err.response.data.error); + } else { + setError("Failed to validate code. Please try again."); + } + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+

+ Community Rentals +

+
+ +
+
    +
  • + Earn extra income with the stuff you already + have +
  • +
  • + Find items for special events, weekend + projects, family trips and more +
  • +
  • + Discover affordable options +
  • +
+
+ +
+
+ Currently in Alpha Testing! +
+

+ You're among the first to try Community Rentals! Help us create + something special by sharing your thoughts as we build this + together. +

+
+ +
+

+ Have an alpha code? Get started below!

Want to join?{" "} + + Request access + +

+
+ +
+
+
+ {/* Static ALPHA- text */} + + ALPHA- + + + {/* Input for 8 characters */} + + + {/* Error icon outside the input */} + {error && ( + + ⚠️ + + )} +
+
+ + +
+
+
+
+ ); +}; + +export default AlphaGate;