feat: SMTP integration, dynamic app_settings for email, and email verification enforcement for tier upgrades
This commit is contained in:
@@ -5,6 +5,8 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
role VARCHAR(50) DEFAULT 'free',
|
role VARCHAR(50) DEFAULT 'free',
|
||||||
|
is_email_verified BOOLEAN DEFAULT false,
|
||||||
|
email_verification_token VARCHAR(255),
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,5 +11,10 @@ INSERT INTO app_settings (key, value, category) VALUES
|
|||||||
('icici_api_key', '', 'payments'),
|
('icici_api_key', '', 'payments'),
|
||||||
('google_safe_browsing_key', '', 'security'),
|
('google_safe_browsing_key', '', 'security'),
|
||||||
('google_client_id', '', 'auth'),
|
('google_client_id', '', 'auth'),
|
||||||
('apple_service_id', '', 'auth')
|
('apple_service_id', '', 'auth'),
|
||||||
|
('smtp_host', 'smtp.office365.com', 'smtp'),
|
||||||
|
('smtp_port', '587', 'smtp'),
|
||||||
|
('smtp_user', 'noreply@cavininfotech.com', 'smtp'),
|
||||||
|
('smtp_pass', 'xmkshjlszjbkcyzb', 'smtp'),
|
||||||
|
('smtp_secure', 'false', 'smtp')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|||||||
Generated
+10
@@ -15,6 +15,7 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"redis": "^6.1.0",
|
"redis": "^6.1.0",
|
||||||
@@ -967,6 +968,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"nodemailer": "^9.0.3",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"redis": "^6.1.0",
|
"redis": "^6.1.0",
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ router.put('/users/:id/tier', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (role === 'pro' || role === 'enterprise') {
|
||||||
|
const userCheck = await pgPool.query('SELECT is_email_verified FROM users WHERE id = $1', [id]);
|
||||||
|
if (userCheck.rows.length === 0) return res.status(404).json({ error: 'User not found' });
|
||||||
|
if (!userCheck.rows[0].is_email_verified) {
|
||||||
|
return res.status(400).json({ error: 'Cannot upgrade tier: User email is not verified' });
|
||||||
|
}
|
||||||
|
}
|
||||||
const result = await pgPool.query(
|
const result = await pgPool.query(
|
||||||
'UPDATE users SET role = $1 WHERE id = $2 RETURNING id, email, role',
|
'UPDATE users SET role = $1 WHERE id = $2 RETURNING id, email, role',
|
||||||
[role, id]
|
[role, id]
|
||||||
|
|||||||
+58
-1
@@ -2,7 +2,9 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { Pool } = require('pg');
|
const { Pool } = require('pg');
|
||||||
|
const { sendMail } = require('../utils/emailService');
|
||||||
|
|
||||||
const pgPool = new Pool({
|
const pgPool = new Pool({
|
||||||
host: process.env.PGHOST || 'postgres',
|
host: process.env.PGHOST || 'postgres',
|
||||||
@@ -72,7 +74,7 @@ router.post('/login', async (req, res) => {
|
|||||||
const { verifyToken } = require('../middleware/auth');
|
const { verifyToken } = require('../middleware/auth');
|
||||||
router.get('/me', verifyToken, async (req, res) => {
|
router.get('/me', verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userQuery = await pgPool.query('SELECT id, email, role FROM users WHERE id = $1', [req.user.id]);
|
const userQuery = await pgPool.query('SELECT id, email, role, is_email_verified FROM users WHERE id = $1', [req.user.id]);
|
||||||
if (userQuery.rows.length === 0) return res.status(404).json({ error: 'User not found' });
|
if (userQuery.rows.length === 0) return res.status(404).json({ error: 'User not found' });
|
||||||
res.json({ user: userQuery.rows[0] });
|
res.json({ user: userQuery.rows[0] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -81,6 +83,61 @@ router.get('/me', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send Verification Email
|
||||||
|
router.post('/send-verification', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userQuery = await pgPool.query('SELECT email, is_email_verified FROM users WHERE id = $1', [req.user.id]);
|
||||||
|
if (userQuery.rows.length === 0) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const user = userQuery.rows[0];
|
||||||
|
if (user.is_email_verified) {
|
||||||
|
return res.status(400).json({ error: 'Email is already verified' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
await pgPool.query('UPDATE users SET email_verification_token = $1 WHERE id = $2', [token, req.user.id]);
|
||||||
|
|
||||||
|
const baseUrl = process.env.FRONTEND_URL || 'https://qr.houseofwebsites.ai';
|
||||||
|
const verifyLink = `${baseUrl}/verify-email?token=${token}`;
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<h2>Verify Your Email</h2>
|
||||||
|
<p>Thanks for using CK-QR! Please verify your email address to unlock Pro tier upgrades.</p>
|
||||||
|
<a href="${verifyLink}" style="display:inline-block;padding:10px 20px;background:#4f46e5;color:#fff;text-decoration:none;border-radius:5px;">Verify Email</a>
|
||||||
|
<p>Or copy and paste this link: <br> ${verifyLink}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
await sendMail(user.email, 'Verify your CK-QR account', html);
|
||||||
|
res.json({ message: 'Verification email sent' });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to send verification email' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify Email Token
|
||||||
|
router.get('/verify-email/:token', async (req, res) => {
|
||||||
|
const { token } = req.params;
|
||||||
|
if (!token) return res.status(400).json({ error: 'Token is required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pgPool.query(
|
||||||
|
'UPDATE users SET is_email_verified = true, email_verification_token = NULL WHERE email_verification_token = $1 RETURNING id, email',
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: 'Email verified successfully!' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error during verification' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
router.post('/logout', (req, res) => {
|
router.post('/logout', (req, res) => {
|
||||||
res.clearCookie('token');
|
res.clearCookie('token');
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
const pgPool = new Pool({
|
||||||
|
host: process.env.PGHOST || 'postgres',
|
||||||
|
port: process.env.PGPORT || 5432,
|
||||||
|
user: process.env.PGUSER || 'postgres',
|
||||||
|
password: process.env.PGPASSWORD || 'postgres',
|
||||||
|
database: process.env.PGDATABASE || 'ckqr',
|
||||||
|
});
|
||||||
|
|
||||||
// Placeholder for ICICI Payment Gateway initialization
|
// Placeholder for ICICI Payment Gateway initialization
|
||||||
router.post('/icici/initiate', async (req, res) => {
|
router.post('/icici/initiate', async (req, res) => {
|
||||||
const { amount, plan, userId } = req.body;
|
const { amount, plan, userId } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userCheck = await pgPool.query('SELECT is_email_verified FROM users WHERE id = $1', [userId]);
|
||||||
|
if (userCheck.rows.length > 0 && !userCheck.rows[0].is_email_verified) {
|
||||||
|
return res.status(400).json({ error: 'Please verify your email address before upgrading your plan.' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error checking verification status', err);
|
||||||
|
return res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
|
||||||
// Here you would construct the ICICI request payload, compute checksums, etc.
|
// Here you would construct the ICICI request payload, compute checksums, etc.
|
||||||
// and return the URL or parameters for the frontend to redirect the user to ICICI.
|
// and return the URL or parameters for the frontend to redirect the user to ICICI.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
const pgPool = new Pool({
|
||||||
|
host: process.env.PGHOST || 'postgres',
|
||||||
|
port: process.env.PGPORT || 5432,
|
||||||
|
user: process.env.PGUSER || 'postgres',
|
||||||
|
password: process.env.PGPASSWORD || 'postgres',
|
||||||
|
database: process.env.PGDATABASE || 'ckqr',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to fetch SMTP settings from the database
|
||||||
|
async function getSmtpSettings() {
|
||||||
|
const result = await pgPool.query("SELECT key, value FROM app_settings WHERE category = 'smtp'");
|
||||||
|
const settings = {};
|
||||||
|
result.rows.forEach(row => {
|
||||||
|
settings[row.key] = row.value;
|
||||||
|
});
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create and configure a transporter
|
||||||
|
async function getTransporter() {
|
||||||
|
const settings = await getSmtpSettings();
|
||||||
|
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: settings.smtp_host || 'smtp.office365.com',
|
||||||
|
port: parseInt(settings.smtp_port) || 587,
|
||||||
|
secure: settings.smtp_secure === 'true', // true for 465, false for other ports
|
||||||
|
auth: {
|
||||||
|
user: settings.smtp_user,
|
||||||
|
pass: settings.smtp_pass,
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
ciphers: 'SSLv3', // sometimes required for Office365
|
||||||
|
rejectUnauthorized: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an email using dynamic SMTP settings from the DB.
|
||||||
|
*
|
||||||
|
* @param {string} to - Recipient email address
|
||||||
|
* @param {string} subject - Email subject
|
||||||
|
* @param {string} html - HTML body of the email
|
||||||
|
*/
|
||||||
|
async function sendMail(to, subject, html) {
|
||||||
|
const settings = await getSmtpSettings();
|
||||||
|
const transporter = await getTransporter();
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: `"CK-QR Support" <${settings.smtp_user}>`,
|
||||||
|
to: to,
|
||||||
|
subject: subject,
|
||||||
|
html: html
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await transporter.sendMail(mailOptions);
|
||||||
|
console.log('Message sent: %s', info.messageId);
|
||||||
|
return { success: true, messageId: info.messageId };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending email:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sendMail, getSmtpSettings };
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, Suspense } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
function VerifyEmailContent() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
||||||
|
const [message, setMessage] = useState('Verifying your email address...');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage('No verification token found. Please check your email link.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifyToken = async () => {
|
||||||
|
try {
|
||||||
|
await api.get(`/auth/verify-email/${token}`);
|
||||||
|
setStatus('success');
|
||||||
|
setMessage('Your email has been verified successfully!');
|
||||||
|
|
||||||
|
// Auto redirect to dashboard after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/dashboard');
|
||||||
|
}, 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatus('error');
|
||||||
|
setMessage(err.response?.data?.error || 'Failed to verify email. The link may have expired.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
verifyToken();
|
||||||
|
}, [token, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8 font-sans">
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Email Verification
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||||
|
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10 text-center">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div className="text-gray-500">
|
||||||
|
<svg className="animate-spin h-8 w-8 mx-auto mb-4 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<p className="font-semibold text-lg">{message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<div>
|
||||||
|
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4">
|
||||||
|
<svg className="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-gray-900 text-lg mb-2">{message}</p>
|
||||||
|
<p className="text-gray-500 text-sm mb-6">Redirecting to your dashboard...</p>
|
||||||
|
<Link href="/dashboard" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Go to Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div>
|
||||||
|
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||||
|
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-gray-900 text-lg mb-4">{message}</p>
|
||||||
|
<Link href="/dashboard" className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerifyEmailPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
|
||||||
|
<VerifyEmailContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user