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,
|
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 API rate limiter
|
||||||
general: rateLimit({
|
general: rateLimit({
|
||||||
windowMs: 60 * 1000, // 1 minute
|
windowMs: 60 * 1000, // 1 minute
|
||||||
@@ -166,6 +178,7 @@ module.exports = {
|
|||||||
loginLimiter: authRateLimiters.login,
|
loginLimiter: authRateLimiters.login,
|
||||||
registerLimiter: authRateLimiters.register,
|
registerLimiter: authRateLimiters.register,
|
||||||
passwordResetLimiter: authRateLimiters.passwordReset,
|
passwordResetLimiter: authRateLimiters.passwordReset,
|
||||||
|
alphaCodeValidationLimiter: authRateLimiters.alphaCodeValidation,
|
||||||
generalLimiter: authRateLimiters.general,
|
generalLimiter: authRateLimiters.general,
|
||||||
|
|
||||||
// Burst protection
|
// 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 ItemRequestResponse = require("./ItemRequestResponse");
|
||||||
const UserAddress = require("./UserAddress");
|
const UserAddress = require("./UserAddress");
|
||||||
const ConditionCheck = require("./ConditionCheck");
|
const ConditionCheck = require("./ConditionCheck");
|
||||||
|
const AlphaInvitation = require("./AlphaInvitation");
|
||||||
|
|
||||||
User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" });
|
User.hasMany(Item, { as: "ownedItems", foreignKey: "ownerId" });
|
||||||
Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
|
Item.belongsTo(User, { as: "owner", foreignKey: "ownerId" });
|
||||||
@@ -71,6 +72,16 @@ ConditionCheck.belongsTo(User, {
|
|||||||
foreignKey: "submittedBy",
|
foreignKey: "submittedBy",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AlphaInvitation associations
|
||||||
|
AlphaInvitation.belongsTo(User, {
|
||||||
|
as: "user",
|
||||||
|
foreignKey: "usedBy",
|
||||||
|
});
|
||||||
|
User.hasMany(AlphaInvitation, {
|
||||||
|
as: "alphaInvitations",
|
||||||
|
foreignKey: "usedBy",
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sequelize,
|
sequelize,
|
||||||
User,
|
User,
|
||||||
@@ -81,4 +92,5 @@ module.exports = {
|
|||||||
ItemRequestResponse,
|
ItemRequestResponse,
|
||||||
UserAddress,
|
UserAddress,
|
||||||
ConditionCheck,
|
ConditionCheck,
|
||||||
|
AlphaInvitation,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,14 @@
|
|||||||
"test:coverage": "jest --coverage --forceExit --maxWorkers=4",
|
"test:coverage": "jest --coverage --forceExit --maxWorkers=4",
|
||||||
"test:unit": "NODE_ENV=test jest tests/unit",
|
"test:unit": "NODE_ENV=test jest tests/unit",
|
||||||
"test:integration": "NODE_ENV=test jest tests/integration",
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"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 express = require("express");
|
||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
const { OAuth2Client } = require("google-auth-library");
|
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 logger = require("../utils/logger");
|
||||||
const emailService = require("../services/emailService");
|
const emailService = require("../services/emailService");
|
||||||
const crypto = require("crypto");
|
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({
|
const user = await User.create({
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
@@ -73,6 +102,13 @@ router.post(
|
|||||||
phone,
|
phone,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Link alpha invitation to user
|
||||||
|
await alphaInvitation.update({
|
||||||
|
usedBy: user.id,
|
||||||
|
usedAt: new Date(),
|
||||||
|
status: "active",
|
||||||
|
});
|
||||||
|
|
||||||
// Generate verification token and send email
|
// Generate verification token and send email
|
||||||
await user.generateVerificationToken();
|
await user.generateVerificationToken();
|
||||||
|
|
||||||
@@ -318,6 +354,20 @@ router.post(
|
|||||||
isVerified: true,
|
isVerified: true,
|
||||||
verifiedAt: new Date(),
|
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
|
// 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 morgan = require("morgan");
|
||||||
|
|
||||||
const authRoutes = require("./routes/auth");
|
const authRoutes = require("./routes/auth");
|
||||||
|
const { router: alphaRoutes } = require("./routes/alpha");
|
||||||
const userRoutes = require("./routes/users");
|
const userRoutes = require("./routes/users");
|
||||||
const itemRoutes = require("./routes/items");
|
const itemRoutes = require("./routes/items");
|
||||||
const rentalRoutes = require("./routes/rentals");
|
const rentalRoutes = require("./routes/rentals");
|
||||||
@@ -41,6 +42,7 @@ const {
|
|||||||
const { generalLimiter } = require("./middleware/rateLimiter");
|
const { generalLimiter } = require("./middleware/rateLimiter");
|
||||||
const errorLogger = require("./middleware/errorLogger");
|
const errorLogger = require("./middleware/errorLogger");
|
||||||
const apiLogger = require("./middleware/apiLogger");
|
const apiLogger = require("./middleware/apiLogger");
|
||||||
|
const { requireAlphaAccess } = require("./middleware/alphaAccess");
|
||||||
|
|
||||||
// Apply security middleware
|
// Apply security middleware
|
||||||
app.use(enforceHTTPS);
|
app.use(enforceHTTPS);
|
||||||
@@ -106,20 +108,25 @@ app.use(
|
|||||||
// Serve static files from uploads directory
|
// Serve static files from uploads directory
|
||||||
app.use("/uploads", express.static(path.join(__dirname, "uploads")));
|
app.use("/uploads", express.static(path.join(__dirname, "uploads")));
|
||||||
|
|
||||||
app.use("/api/auth", authRoutes);
|
// Public routes (no alpha access required)
|
||||||
app.use("/api/users", userRoutes);
|
app.use("/api/alpha", alphaRoutes);
|
||||||
app.use("/api/items", itemRoutes);
|
app.use("/api/auth", authRoutes); // Auth has its own alpha checks in registration
|
||||||
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);
|
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.json({ message: "CommunityRentals.App API is running!" });
|
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)
|
// Error handling middleware (must be last)
|
||||||
app.use(errorLogger);
|
app.use(errorLogger);
|
||||||
app.use(sanitizeError);
|
app.use(sanitizeError);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class EmailService {
|
|||||||
"rentalCompletionCongratsToOwner.html",
|
"rentalCompletionCongratsToOwner.html",
|
||||||
"payoutReceivedToOwner.html",
|
"payoutReceivedToOwner.html",
|
||||||
"firstListingCelebrationToOwner.html",
|
"firstListingCelebrationToOwner.html",
|
||||||
|
"alphaInvitationToUser.html",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const templateFile of templateFiles) {
|
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) {
|
async sendPasswordResetEmail(user, resetToken) {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
|
||||||
const resetUrl = `${frontendUrl}/reset-password?token=${resetToken}`;
|
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>
|
||||||
@@ -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 { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
import AuthModal from './components/AuthModal';
|
import AuthModal from './components/AuthModal';
|
||||||
|
import AlphaGate from './components/AlphaGate';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import GoogleCallback from './pages/GoogleCallback';
|
import GoogleCallback from './pages/GoogleCallback';
|
||||||
import VerifyEmail from './pages/VerifyEmail';
|
import VerifyEmail from './pages/VerifyEmail';
|
||||||
@@ -25,10 +26,49 @@ import CreateItemRequest from './pages/CreateItemRequest';
|
|||||||
import MyRequests from './pages/MyRequests';
|
import MyRequests from './pages/MyRequests';
|
||||||
import EarningsDashboard from './pages/EarningsDashboard';
|
import EarningsDashboard from './pages/EarningsDashboard';
|
||||||
import PrivateRoute from './components/PrivateRoute';
|
import PrivateRoute from './components/PrivateRoute';
|
||||||
|
import axios from 'axios';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
|
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5001';
|
||||||
|
|
||||||
const AppContent: React.FC = () => {
|
const AppContent: React.FC = () => {
|
||||||
const { showAuthModal, authModalMode, closeAuthModal } = useAuth();
|
const { showAuthModal, authModalMode, closeAuthModal } = useAuth();
|
||||||
|
const [hasAlphaAccess, setHasAlphaAccess] = useState<boolean | null>(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 (
|
||||||
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
215
frontend/src/components/AlphaGate.tsx
Normal file
215
frontend/src/components/AlphaGate.tsx
Normal 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;
|
||||||
Reference in New Issue
Block a user