alpha
This commit is contained in:
59
backend/middleware/alphaAccess.js
Normal file
59
backend/middleware/alphaAccess.js
Normal file
@@ -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 };
|
||||
@@ -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
|
||||
|
||||
69
backend/models/AlphaInvitation.js
Normal file
69
backend/models/AlphaInvitation.js
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
127
backend/routes/alpha.js
Normal file
127
backend/routes/alpha.js
Normal file
@@ -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 };
|
||||
@@ -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
|
||||
|
||||
480
backend/scripts/manageAlphaInvitations.js
Normal file
480
backend/scripts/manageAlphaInvitations.js
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
283
backend/templates/emails/alphaInvitationToUser.html
Normal file
283
backend/templates/emails/alphaInvitationToUser.html
Normal 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>© 2024 RentAll. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user