Compare commits

...

2 Commits

Author SHA1 Message Date
jackiettran
71ce2c63fb alpha testing feature flag 2025-10-30 16:16:27 -04:00
jackiettran
ee3a6fd8e1 alpha 2025-10-30 15:38:57 -04:00
13 changed files with 1421 additions and 12 deletions

View File

@@ -0,0 +1,64 @@
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 {
// Bypass alpha access check if feature is disabled
if (process.env.ALPHA_TESTING_ENABLED !== 'true') {
return next();
}
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 };

View File

@@ -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

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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": "",

132
backend/routes/alpha.js Normal file
View File

@@ -0,0 +1,132 @@
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) {
// Bypass alpha access check if feature is disabled
if (process.env.ALPHA_TESTING_ENABLED !== 'true') {
return true;
}
// 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 };

View File

@@ -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,37 @@ router.post(
});
}
// Alpha access validation
let alphaInvitation = null;
if (process.env.ALPHA_TESTING_ENABLED === 'true') {
if (req.cookies && req.cookies.alphaAccessCode) {
const { code } = req.cookies.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 +104,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 +356,22 @@ router.post(
isVerified: true,
verifiedAt: new Date(),
});
// Check if there's an alpha invitation for this email
if (process.env.ALPHA_TESTING_ENABLED === 'true') {
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

View File

@@ -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 <command> [options]
Commands:
add <email> [notes] Add a new alpha invitation
list [filter] List all invitations (filter: all|pending|active|revoked|unused)
revoke <code> Revoke an invitation code
restore <code> Restore a revoked invitation
resend <email|code> Resend an invitation email
bulk <csvPath> 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 <email> [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 <code>\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 <email|code>\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 <code>\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 <csvPath>\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,
};

View File

@@ -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);

View File

@@ -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 <strong>${code}</strong> 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}`;

View File

@@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Your Alpha Access Code - RentAll</title>
<style>
/* Reset styles */
body, table, td, p, a, li, blockquote {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table, td {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
-ms-interpolation-mode: bicubic;
}
/* Base styles */
body {
margin: 0;
padding: 0;
width: 100% !important;
min-width: 100%;
height: 100%;
background-color: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #212529;
}
/* Container */
.email-container {
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 40px 30px;
text-align: center;
}
.logo {
font-size: 32px;
font-weight: 700;
color: #ffffff;
text-decoration: none;
letter-spacing: -1px;
}
.tagline {
color: #e0e7ff;
font-size: 14px;
margin-top: 8px;
}
/* Content */
.content {
padding: 40px 30px;
}
.content h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 20px 0;
color: #212529;
}
.content p {
margin: 0 0 16px 0;
color: #6c757d;
line-height: 1.6;
}
.content strong {
color: #495057;
}
/* Code box */
.code-box {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
padding: 30px;
margin: 30px 0;
text-align: center;
}
.code-label {
color: #e0e7ff;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 12px;
}
.code {
font-family: 'Courier New', Courier, monospace;
font-size: 32px;
font-weight: 700;
color: #ffffff;
letter-spacing: 4px;
margin: 10px 0;
user-select: all;
}
/* Button */
.button {
display: inline-block;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 6px;
font-weight: 600;
margin: 20px 0;
text-align: center;
transition: all 0.3s ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
/* Info box */
.info-box {
background-color: #e7f3ff;
border-left: 4px solid #667eea;
padding: 20px;
margin: 20px 0;
border-radius: 0 6px 6px 0;
}
.info-box p {
margin: 0 0 10px 0;
color: #004085;
font-size: 14px;
}
.info-box p:last-child {
margin-bottom: 0;
}
.info-box ul {
margin: 10px 0 0 0;
padding-left: 20px;
color: #004085;
font-size: 14px;
}
.info-box li {
margin-bottom: 6px;
}
/* Footer */
.footer {
background-color: #f8f9fa;
padding: 30px;
text-align: center;
border-top: 1px solid #e9ecef;
}
.footer p {
margin: 0 0 10px 0;
font-size: 14px;
color: #6c757d;
}
.footer a {
color: #667eea;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive */
@media only screen and (max-width: 600px) {
.email-container {
margin: 0;
border-radius: 0;
}
.header, .content, .footer {
padding: 20px;
}
.logo {
font-size: 28px;
}
.content h1 {
font-size: 22px;
}
.code {
font-size: 24px;
letter-spacing: 2px;
}
.button {
display: block;
width: 100%;
box-sizing: border-box;
}
}
</style>
</head>
<body>
<div class="email-container">
<div class="header">
<div class="logo">RentAll</div>
<div class="tagline">Alpha Access Invitation</div>
</div>
<div class="content">
<h1>Welcome to Alpha Testing!</h1>
<p>Congratulations! You've been selected to participate in the exclusive alpha testing program for RentAll, the community-powered rental marketplace.</p>
<p>Your unique alpha access code is: <strong style="font-family: monospace;">{{code}}</strong></p>
<p>To get started:</p>
<div class="info-box">
<p><strong>Steps to Access:</strong></p>
<ul>
<li>Visit <a href="{{frontendUrl}}" style="color: #667eea; font-weight: 600;">{{frontendUrl}}</a></li>
<li>Enter your alpha access code when prompted</li>
<li>Register with <strong>this email address</strong> ({{email}})</li>
<li>Start exploring the platform!</li>
</ul>
</div>
<div style="text-align: center;">
<a href="{{frontendUrl}}" class="button">Access RentAll Alpha</a>
</div>
<p><strong>What to expect as an alpha tester:</strong></p>
<div class="info-box">
<ul>
<li>Early access to new features before public launch</li>
<li>Opportunity to shape the product with your feedback</li>
<li>Direct communication with the development team</li>
<li>Special recognition as an early supporter</li>
</ul>
</div>
<p><strong>Important notes:</strong></p>
<ul style="color: #6c757d; font-size: 14px;">
<li>Your code is tied to this email address only</li>
<li>This is a permanent access code (no expiration)</li>
<li>Please keep your code confidential</li>
<li>We value your feedback - let us know what you think!</li>
</ul>
<p>We're excited to have you as part of our alpha testing community. Your feedback will be invaluable in making RentAll the best it can be.</p>
<p>If you have any questions or encounter any issues, please don't hesitate to reach out to us.</p>
<p>Happy renting!</p>
</div>
<div class="footer">
<p><strong>RentAll Alpha Testing Program</strong></p>
<p>Need help? Contact us at <a href="mailto:support@rentall.app">support@rentall.app</a></p>
<p>&copy; 2024 RentAll. All rights reserved.</p>
</div>
</div>
</body>
</html>

View File

@@ -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,56 @@ 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<boolean | null>(null);
const [checkingAccess, setCheckingAccess] = useState(true);
useEffect(() => {
const checkAlphaAccess = async () => {
// Bypass alpha access check if feature is disabled
if (process.env.REACT_APP_ALPHA_TESTING_ENABLED !== 'true') {
setHasAlphaAccess(true);
setCheckingAccess(false);
return;
}
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 (
<div className="d-flex align-items-center justify-content-center min-vh-100">
<div className="spinner-border text-primary" role="status">
<span className="visually-hidden">Loading...</span>
</div>
</div>
);
}
// Show alpha gate if no access
if (!hasAlphaAccess) {
return <AlphaGate />;
}
return (
<>

View File

@@ -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<HTMLInputElement>) => {
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 (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
padding: "20px",
}}
>
<div
className="card shadow-lg"
style={{ maxWidth: "500px", width: "100%" }}
>
<div className="card-body p-5">
<div className="text-center mb-4">
<h1 className="h2 mb-3" style={{ color: "#667eea" }}>
Community Rentals
</h1>
</div>
<div className="mb-4">
<ul
className="text-start text-muted mb-3"
style={{ fontSize: "0.95rem" }}
>
<li className="mb-2">
<strong>Earn</strong> extra income with the stuff you already
have
</li>
<li className="mb-2">
<strong>Find</strong> items for special events, weekend
projects, family trips and more
</li>
<li className="mb-2">
<strong>Discover</strong> affordable options
</li>
</ul>
</div>
<div className="mb-4">
<h6
className="fw-bold mb-2 text-center"
style={{ color: "#667eea" }}
>
Currently in Alpha Testing!
</h6>
<p className="text-muted small mb-0 text-center">
You're among the first to try Community Rentals! Help us create
something special by sharing your thoughts as we build this
together.
</p>
</div>
<div className="mb-4">
<p className="text-center text-muted small mb-0">
Have an alpha code? Get started below! <br></br> Want to join?{" "}
<a
href="mailto:support@communityrentals.app?subject=Alpha Access Request"
className="text-decoration-none"
style={{ color: "#667eea" }}
>
Request access
</a>
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="mb-3">
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
}}
>
{/* Static ALPHA- text */}
<span
style={{
fontFamily: "monospace",
fontSize: "1.1rem",
fontWeight: "400",
letterSpacing: "2px",
color: "#495057",
}}
>
ALPHA-
</span>
{/* Input for 8 characters */}
<input
type="text"
className={`form-control form-control-lg ${
error ? "border-danger" : ""
}`}
placeholder="XXXXXXXX"
value={code}
onChange={handleCodeChange}
disabled={loading}
style={{
fontFamily: "monospace",
letterSpacing: "2px",
fontSize: "1.1rem",
textAlign: "center",
width: "calc(8ch + 16px + 2rem)",
}}
maxLength={8}
autoFocus
/>
{/* Error icon outside the input */}
{error && (
<span
style={{
color: "#dc3545",
fontSize: "1.5rem",
lineHeight: 1,
}}
>
</span>
)}
</div>
</div>
<button
type="submit"
className="btn btn-primary btn-lg w-100"
disabled={loading || code.length < 8}
style={{
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
border: "none",
}}
>
{loading ? (
<>
<span
className="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
Validating...
</>
) : (
"Enter"
)}
</button>
</form>
</div>
</div>
</div>
);
};
export default AlphaGate;