Preview: authController.js
Size: 30.77 KB
/home/byroehnu/easepay.easetack.com/controllers/authController.js
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { prisma } = require('../config/prisma');
const otpService = require('../services/otpService');
// Validate JWT secret on startup
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
console.error('❌ JWT_SECRET must be at least 32 characters long');
process.exit(1);
}
// Generate JWT token
const generateToken = (id) => {
return jwt.sign({ id }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN || '7d'
});
};
// Generate OTP
const generateOTP = () => {
return Math.floor(100000 + Math.random() * 900000).toString();
};
// Send OTP
const sendOTP = async (req, res) => {
try {
const { phone, method = 'sms' } = req.body;
if (!phone) {
return res.status(400).json({
success: false,
message: 'Please provide phone number'
});
}
// Validate phone number format
const phoneValidation = otpService.validatePhoneNumber(phone);
if (!phoneValidation.isValid) {
return res.status(400).json({
success: false,
message: phoneValidation.error
});
}
const formattedPhone = phoneValidation.formatted;
// Check if user exists, if not create a temporary user record
const existingUser = await prisma.user.findUnique({
where: { phone: formattedPhone }
});
try {
// Send OTP via selected method using OTP.dev
let sendResult;
if (method === 'whatsapp') {
sendResult = await otpService.sendWhatsApp(formattedPhone);
} else {
sendResult = await otpService.sendSMS(formattedPhone);
}
if (!sendResult.success) {
throw new Error(sendResult.message || 'Failed to send OTP');
}
// Calculate expiry time
const otpExpiresAt = new Date(Date.now() + (process.env.OTP_EXPIRY_MINUTES || 10) * 60 * 1000);
if (existingUser) {
// Update existing user with verification ID
await prisma.user.update({
where: { phone: formattedPhone },
data: {
otpVerificationId: sendResult.verificationId,
otpExpiresAt: otpExpiresAt
}
});
} else {
// Create new user with verification ID
await prisma.user.create({
data: {
phone: formattedPhone,
email: `temp_${Date.now()}_${formattedPhone.replace(/[^0-9]/g, '')}@easepay.temp`,
password: await bcrypt.hash('temporary_password_' + Date.now(), 12),
firstName: 'User',
lastName: 'Temp',
otpVerificationId: sendResult.verificationId,
otpExpiresAt: otpExpiresAt
}
});
}
console.log(`✅ OTP sent successfully to ${formattedPhone} via ${method}`);
console.log(`⏰ OTP expires at: ${otpExpiresAt}`);
console.log(`🆔 Verification ID: ${sendResult.verificationId}`);
res.status(200).json({
success: true,
message: `OTP sent successfully via ${method}`,
phone: formattedPhone,
verificationId: sendResult.verificationId,
expiresIn: `${process.env.OTP_EXPIRY_MINUTES || 10} minutes`,
expiresAt: sendResult.expiresAt || otpExpiresAt
});
} catch (smsError) {
console.error('❌ OTP sending failed:', smsError.message);
res.status(500).json({
success: false,
message: 'Failed to send OTP. Please try again.',
error: process.env.NODE_ENV === 'development' ? smsError.message : undefined
});
}
} catch (error) {
console.error('Send OTP error:', error);
res.status(500).json({
success: false,
message: 'Failed to send OTP'
});
}
};
// Verify OTP
const verifyOTP = async (req, res) => {
try {
const { phone, otp, verificationId } = req.body;
console.log(`🔍 === OTP VERIFICATION DEBUG ===`);
console.log(`📞 Phone: ${phone}`);
console.log(`🔢 Received OTP: "${otp}" (length: ${otp?.length}, type: ${typeof otp})`);
console.log(`🆔 Verification ID: ${verificationId}`);
if (!phone || !otp) {
console.log(`❌ Missing phone or OTP`);
return res.status(400).json({
success: false,
message: 'Please provide phone number and OTP'
});
}
// Validate OTP format
if (!/^\d{5}$/.test(otp)) {
console.log(`❌ Invalid OTP format: "${otp}"`);
return res.status(400).json({
success: false,
message: 'OTP must be exactly 5 digits'
});
}
console.log(`🔍 Verifying OTP for ${phone}: ${otp}`);
// Find user with matching phone
const user = await prisma.user.findUnique({
where: { phone },
select: {
id: true,
phone: true,
otpVerificationId: true,
otpExpiresAt: true,
phoneVerified: true,
firstName: true,
lastName: true,
email: true,
role: true
}
});
if (!user) {
console.log(`❌ User not found for phone: ${phone}`);
return res.status(404).json({
success: false,
message: 'User not found. Please request OTP first.'
});
}
console.log(`📋 User found:`);
console.log(` - Phone: ${user.phone}`);
console.log(` - Verification ID: ${user.otpVerificationId}`);
console.log(` - OTP Expires: ${user.otpExpiresAt}`);
console.log(` - Phone Verified: ${user.phoneVerified}`);
// Check if OTP is expired
if (!user.otpExpiresAt || new Date() > user.otpExpiresAt) {
console.log(`❌ OTP expired. Expires: ${user.otpExpiresAt}, Now: ${new Date()}`);
return res.status(400).json({
success: false,
message: 'OTP has expired. Please request a new one.'
});
}
try {
let verificationResult = { success: false };
// Fallback: Allow any 5-digit OTP in development
if (process.env.NODE_ENV === 'development' && otp && otp.length === 5) {
console.log(`🛠️ Development fallback - accepting OTP: ${otp}`);
verificationResult = { success: true, message: 'Development mode OTP accepted' };
} else {
// Use OTP.dev to verify the OTP
verificationResult = await otpService.verifyOTP(
verificationId || user.otpVerificationId,
otp
);
}
if (!verificationResult.success) {
console.log(`❌ OTP verification failed: ${verificationResult.message}`);
return res.status(400).json({
success: false,
message: verificationResult.message || 'Invalid OTP'
});
}
console.log(`✅ OTP verification passed!`);
// Mark phone as verified and clear OTP data
await prisma.user.update({
where: { id: user.id },
data: {
phoneVerified: true,
otpVerificationId: null,
otpExpiresAt: null
}
});
// Generate token
const token = generateToken(user.id);
console.log(`✅ Phone ${phone} verified successfully! Token generated.`);
res.status(200).json({
success: true,
message: 'Phone verified successfully',
token,
user: {
id: user.id,
phone: user.phone,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
role: user.role,
phoneVerified: true
}
});
} catch (verificationError) {
console.error('❌ OTP.dev verification error:', verificationError.message);
return res.status(400).json({
success: false,
message: 'OTP verification failed. Please try again.'
});
}
} catch (error) {
console.error('Verify OTP error:', error);
res.status(500).json({
success: false,
message: 'Failed to verify OTP'
});
}
};
// Phone-based login (send OTP for existing users)
const phoneLogin = async (req, res) => {
try {
const { phone } = req.body;
if (!phone) {
return res.status(400).json({
success: false,
message: 'Please provide phone number'
});
}
// Check if user exists
const user = await prisma.user.findUnique({
where: { phone }
});
if (!user) {
return res.status(404).json({
success: false,
message: 'No account found with this phone number. Please register first.'
});
}
// Generate OTP
const otp = generateOTP();
const otpExpiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
// Update user with OTP
await prisma.user.update({
where: { phone },
data: {
otp,
otpExpiresAt
}
});
// Log OTP to console (in production, send via SMS)
console.log(`📱 Login OTP for ${phone}: ${otp}`);
console.log(`⏰ OTP expires at: ${otpExpiresAt}`);
res.status(200).json({
success: true,
message: 'OTP sent for login',
phone,
// In development, include OTP in response (remove in production)
otp: otp
});
} catch (error) {
console.error('Phone login error:', error);
res.status(500).json({
success: false,
message: 'Failed to send login OTP'
});
}
};
const registerUser = async (req, res) => {
try {
const { email, password, firstName, lastName, phone, businessName } = req.body;
// Validation
if (!email || !password || !firstName || !lastName) {
return res.status(400).json({
success: false,
message: 'Please provide all required fields'
});
}
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email }
});
if (existingUser) {
return res.status(400).json({
success: false,
message: 'User already exists with this email'
});
}
// Hash password
const salt = await bcrypt.genSalt(12);
const hashedPassword = await bcrypt.hash(password, salt);
// Create user
const newUser = await prisma.user.create({
data: {
email,
password: hashedPassword,
firstName,
lastName,
phone,
businessName
},
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
businessName: true,
createdAt: true
}
});
// Generate token
const token = generateToken(newUser.id);
res.status(201).json({
success: true,
message: 'User registered successfully',
token,
user: newUser
});
} catch (error) {
console.error('Register error:', error);
res.status(500).json({
success: false,
message: 'Registration failed'
});
}
};
const loginUser = async (req, res) => {
try {
const { email, password } = req.body;
// Validation
if (!email || !password) {
return res.status(400).json({
success: false,
message: 'Please provide email and password'
});
}
// Check if user exists
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
password: true,
firstName: true,
lastName: true,
phone: true,
businessName: true,
isActive: true
}
});
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Check if user is active
if (!user.isActive) {
return res.status(401).json({
success: false,
message: 'Account has been deactivated'
});
}
// Check password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Generate token
const token = generateToken(user.id);
// Remove password from response
const { password: _, ...userWithoutPassword } = user;
res.status(200).json({
success: true,
message: 'Login successful',
token,
user: userWithoutPassword
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
success: false,
message: 'Login failed'
});
}
};
const getMe = async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
phone: true,
businessName: true,
isActive: true,
emailVerified: true,
createdAt: true
}
});
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
res.status(200).json({
success: true,
user
});
} catch (error) {
console.error('Get me error:', error);
res.status(500).json({
success: false,
message: 'Failed to get user data'
});
}
};
const getInvitationDetails = async (req, res) => {
try {
const { token } = req.params;
const invitation = await prisma.staffInvitation.findUnique({
where: { token },
include: {
business: {
select: {
name: true
}
}
}
});
if (!invitation) {
return res.status(404).json({ success: false, message: 'Invitation not found' });
}
if (invitation.status !== 'PENDING') {
return res.status(400).json({ success: false, message: `Invitation has already been ${invitation.status.toLowerCase()}` });
}
if (new Date() > invitation.expiresAt) {
return res.status(400).json({ success: false, message: 'Invitation has expired' });
}
res.status(200).json({
success: true,
data: {
businessName: invitation.business.name,
role: invitation.role,
expiresAt: invitation.expiresAt
}
});
} catch (error) {
console.error('Get invitation details error:', error);
res.status(500).json({ success: false, message: 'Failed to fetch invitation details' });
}
};
const acceptStaffInvitation = async (req, res) => {
try {
const { token } = req.body;
const userId = req.user.id;
const invitation = await prisma.staffInvitation.findUnique({
where: { token }
});
if (!invitation || invitation.status !== 'PENDING' || new Date() > invitation.expiresAt) {
return res.status(400).json({ success: false, message: 'Invalid or expired invitation' });
}
// Check if user is already staff
const existingStaff = await prisma.businessStaff.findUnique({
where: {
businessId_userId: {
businessId: invitation.businessId,
userId
}
}
});
if (existingStaff) {
return res.status(400).json({ success: false, message: 'You are already a staff member of this business' });
}
// Link user to business
await prisma.$transaction([
prisma.businessStaff.create({
data: {
businessId: invitation.businessId,
userId: userId,
role: invitation.role,
permissions: invitation.permissions
}
}),
prisma.staffInvitation.update({
where: { id: invitation.id },
data: {
status: 'ACCEPTED',
acceptedById: userId
}
}),
prisma.business.update({
where: { id: invitation.businessId },
data: { hasStaff: true }
})
]);
res.status(200).json({
success: true,
message: 'Invitation accepted successfully'
});
} catch (error) {
console.error('Accept invitation error:', error);
res.status(500).json({ success: false, message: 'Failed to accept invitation' });
}
};
// Setup Password (for settings)
const setupPassword = async (req, res) => {
try {
const { password, confirmPassword } = req.body;
const userId = req.user.id;
// Validation
if (!password || !confirmPassword) {
return res.status(400).json({
success: false,
message: 'Password and confirmation are required'
});
}
if (password !== confirmPassword) {
return res.status(400).json({
success: false,
message: 'Passwords do not match'
});
}
// Password strength validation
if (password.length < 6) {
return res.status(400).json({
success: false,
message: 'Password must be at least 6 characters long'
});
}
// Check if user exists
const user = await prisma.user.findUnique({
where: { id: userId }
});
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
// Hash the password
const salt = await bcrypt.genSalt(12);
const hashedPassword = await bcrypt.hash(password, salt);
// Update user with password
await prisma.user.update({
where: { id: userId },
data: {
password: hashedPassword,
updatedAt: new Date()
}
});
res.status(200).json({
success: true,
message: 'Password setup successful. You can now sign in with your email/phone and password.'
});
} catch (error) {
console.error('Setup password error:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
error: process.env.NODE_ENV === 'development' ? error.message : 'Something went wrong'
});
}
};
// Sign In with Email/Phone and Password
const signIn = async (req, res) => {
try {
const { identifier, password } = req.body;
// Validation
if (!identifier || !password) {
return res.status(400).json({
success: false,
message: 'Email/phone and password are required'
});
}
// Find user by email or phone
const user = await prisma.user.findFirst({
where: {
OR: [
{ email: identifier },
{ phone: identifier }
],
isActive: true
}
});
if (!user) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Check if user has set up a password
if (!user.password) {
return res.status(400).json({
success: false,
message: 'Password not set. Please set up your password in settings first.'
});
}
// Verify password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
// Increment login attempts
await prisma.user.update({
where: { id: user.id },
data: {
loginAttempts: user.loginAttempts + 1,
...(user.loginAttempts >= 4 && {
lockedUntil: new Date(Date.now() + 15 * 60 * 1000) // Lock for 15 minutes
})
}
});
return res.status(401).json({
success: false,
message: 'Invalid credentials',
...(user.loginAttempts >= 4 && {
lockout: 'Account temporarily locked due to multiple failed attempts'
})
});
}
// Check if account is locked
if (user.lockedUntil && user.lockedUntil > new Date()) {
return res.status(423).json({
success: false,
message: 'Account is temporarily locked. Please try again later.'
});
}
// Generate session token
const sessionToken = jwt.sign({ id: user.id }, process.env.JWT_SECRET, {
expiresIn: '7d'
});
// Update user login info
await prisma.user.update({
where: { id: user.id },
data: {
lastLoginAt: new Date(),
sessionToken,
sessionExpiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
loginAttempts: 0, // Reset login attempts on successful login
lockedUntil: null // Remove any lockout
}
});
// Generate JWT token
const token = generateToken(user.id);
res.status(200).json({
success: true,
message: 'Sign in successful',
token,
user: {
id: user.id,
email: user.email,
phone: user.phone,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
emailVerified: user.emailVerified,
phoneVerified: user.phoneVerified,
biometricEnabled: user.biometricEnabled,
lastLoginAt: new Date()
}
});
} catch (error) {
console.error('Sign in error:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
error: process.env.NODE_ENV === 'development' ? error.message : 'Something went wrong'
});
}
};
// Check Password Status
const getPasswordStatus = async (req, res) => {
try {
const userId = req.user.id;
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
phone: true,
password: true
}
});
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
res.status(200).json({
success: true,
data: {
hasPassword: !!user.password,
canSignIn: !!user.password,
email: user.email,
phone: user.phone
}
});
} catch (error) {
console.error('Get password status error:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
error: process.env.NODE_ENV === 'development' ? error.message : 'Something went wrong'
});
}
};
// Change Password (for users who already have a password)
const changePassword = async (req, res) => {
try {
const { currentPassword, newPassword, confirmNewPassword } = req.body;
const userId = req.user.id;
// Validation
if (!currentPassword || !newPassword || !confirmNewPassword) {
return res.status(400).json({
success: false,
message: 'Current password, new password, and confirmation are required'
});
}
if (newPassword !== confirmNewPassword) {
return res.status(400).json({
success: false,
message: 'New passwords do not match'
});
}
if (newPassword.length < 6) {
return res.status(400).json({
success: false,
message: 'New password must be at least 6 characters long'
});
}
// Get user
const user = await prisma.user.findUnique({
where: { id: userId }
});
if (!user || !user.password) {
return res.status(400).json({
success: false,
message: 'No current password found. Please set up a password first.'
});
}
// Verify current password
const isCurrentPasswordValid = await bcrypt.compare(currentPassword, user.password);
if (!isCurrentPasswordValid) {
return res.status(401).json({
success: false,
message: 'Current password is incorrect'
});
}
// Hash new password
const salt = await bcrypt.genSalt(12);
const hashedNewPassword = await bcrypt.hash(newPassword, salt);
// Update password
await prisma.user.update({
where: { id: userId },
data: {
password: hashedNewPassword,
updatedAt: new Date()
}
});
res.status(200).json({
success: true,
message: 'Password changed successfully'
});
} catch (error) {
console.error('Change password error:', error);
res.status(500).json({
success: false,
message: 'Internal server error',
error: process.env.NODE_ENV === 'development' ? error.message : 'Something went wrong'
});
}
};
// Check user status by phone (for frontend auth flow)
const checkUserStatus = async (req, res) => {
try {
const { phone } = req.body;
if (!phone) {
return res.status(400).json({
success: false,
message: 'Phone number is required'
});
}
// Validate phone number format
const phoneValidation = otpService.validatePhoneNumber(phone);
if (!phoneValidation.isValid) {
return res.status(400).json({
success: false,
message: phoneValidation.error
});
}
const formattedPhone = phoneValidation.formatted;
// Check if user exists
const user = await prisma.user.findUnique({
where: { phone: formattedPhone },
select: {
id: true,
phone: true,
phoneVerified: true,
password: true,
firstName: true,
lastName: true,
email: true,
isActive: true
}
});
if (!user) {
// New user - needs to go through OTP verification
return res.json({
success: true,
userStatus: 'new',
requiresOTP: true,
requiresPasswordSetup: false,
canSignIn: false,
message: 'New user - phone verification required'
});
}
if (!user.phoneVerified) {
// Existing user but phone not verified
return res.json({
success: true,
userStatus: 'unverified',
requiresOTP: true,
requiresPasswordSetup: false,
canSignIn: false,
message: 'Phone verification required'
});
}
const hasPassword = user.password && user.password !== null;
if (!hasPassword) {
// Phone verified but no password set
return res.json({
success: true,
userStatus: 'verified_no_password',
requiresOTP: false,
requiresPasswordSetup: true,
canSignIn: false,
message: 'Password setup required'
});
}
// User exists, phone verified, password set - can sign in directly
return res.json({
success: true,
userStatus: 'complete',
requiresOTP: false,
requiresPasswordSetup: false,
canSignIn: true,
message: 'User can sign in with password',
user: {
firstName: user.firstName,
lastName: user.lastName,
phone: user.phone
}
});
} catch (error) {
console.error('Check user status error:', error);
res.status(500).json({
success: false,
message: 'Failed to check user status'
});
}
};
// Complete Onboarding (After OTP Verification)
const completeOnboarding = async (req, res) => {
try {
const {
phone,
businessName,
businessAddress,
taxId,
bnNumber,
rcNumber,
businessType,
receiveMoneyMethods,
collectsVat,
hasStaff,
pin,
password
} = req.body;
if (!phone || !businessName || !businessType) {
return res.status(400).json({
success: false,
message: 'Phone, Business Name, and Business Type are required'
});
}
// Find the user created during OTP step
const user = await prisma.user.findUnique({
where: { phone }
});
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found. Please verify OTP first.'
});
}
// Hash PIN and Password if provided
let updateData = {
businessName,
isActive: true
};
if (pin) {
updateData.pin = await bcrypt.hash(pin, 10);
updateData.pinSetAt = new Date();
}
if (password) {
updateData.password = await bcrypt.hash(password, 12);
}
// Update User
const updatedUser = await prisma.user.update({
where: { phone },
data: updateData
});
// Create Business and Business Profile
const business = await prisma.business.create({
data: {
name: businessName,
type: businessType,
ownerId: updatedUser.id,
vatRegistered: collectsVat || false,
hasStaff: hasStaff || false,
profile: {
create: {
address: businessAddress,
tinNumber: taxId,
bnNumber: bnNumber,
rcNumber: rcNumber,
receiveMoneyMethods: receiveMoneyMethods || ["CASH", "TRANSFER", "POS"]
}
}
}
});
// Create default User Settings (using upsert to avoid duplicate errors if retried)
await prisma.userSettings.upsert({
where: { userId: updatedUser.id },
update: {},
create: {
userId: updatedUser.id
}
});
// Generate token
const token = generateToken(updatedUser.id);
res.status(200).json({
success: true,
message: 'Account created successfully',
token,
user: {
id: updatedUser.id,
phone: updatedUser.phone,
businessName: updatedUser.businessName,
businessId: business.id
}
});
} catch (error) {
console.error('Complete onboarding error:', error);
res.status(500).json({
success: false,
message: 'Failed to complete onboarding',
error: error.message
});
}
};
// Delete Account
const deleteAccount = async (req, res) => {
try {
const userId = req.user.id;
// Delete user (Cascade will handle related records if set up, otherwise manual deletion needed)
// Assuming Prisma schema has onDelete: Cascade for most relations
await prisma.user.delete({
where: { id: userId }
});
res.status(200).json({
success: true,
message: 'Account deleted successfully'
});
} catch (error) {
console.error('Delete account error:', error);
res.status(500).json({
success: false,
message: 'Failed to delete account'
});
}
};
module.exports = {
registerUser,
loginUser,
getMe,
getInvitationDetails,
acceptStaffInvitation,
sendOTP,
verifyOTP,
phoneLogin,
setupPassword,
signIn,
getPasswordStatus,
changePassword,
checkUserStatus,
completeOnboarding,
deleteAccount
};
Directory Contents
Dirs: 1 × Files: 17