From b328b0429a22ecaa024da56395afc101ac1147c7 Mon Sep 17 00:00:00 2001 From: Mohan Ki Date: Mon, 27 Jul 2026 18:54:19 +0530 Subject: [PATCH] feat: SMTP integration, dynamic app_settings for email, and email verification enforcement for tier upgrades --- api/db/init.sql | 2 + api/db/migrations/02_app_settings.sql | 7 +- api/package-lock.json | 10 +++ api/package.json | 1 + api/routes/admin.js | 7 ++ api/routes/auth.js | 59 ++++++++++++++- api/routes/payments.js | 19 +++++ api/utils/emailService.js | 69 ++++++++++++++++++ client/src/app/verify-email/page.tsx | 101 ++++++++++++++++++++++++++ 9 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 api/utils/emailService.js create mode 100644 client/src/app/verify-email/page.tsx diff --git a/api/db/init.sql b/api/db/init.sql index 27a2f36..c4afde5 100644 --- a/api/db/init.sql +++ b/api/db/init.sql @@ -5,6 +5,8 @@ CREATE TABLE IF NOT EXISTS users ( email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, 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 ); diff --git a/api/db/migrations/02_app_settings.sql b/api/db/migrations/02_app_settings.sql index 9ad79b1..c065285 100644 --- a/api/db/migrations/02_app_settings.sql +++ b/api/db/migrations/02_app_settings.sql @@ -11,5 +11,10 @@ INSERT INTO app_settings (key, value, category) VALUES ('icici_api_key', '', 'payments'), ('google_safe_browsing_key', '', 'security'), ('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; diff --git a/api/package-lock.json b/api/package-lock.json index f359045..ea5bdf3 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -15,6 +15,7 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", + "nodemailer": "^9.0.3", "pg": "^8.22.0", "qrcode": "^1.5.4", "redis": "^6.1.0", @@ -967,6 +968,15 @@ "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": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/api/package.json b/api/package.json index 3863260..746b7e2 100644 --- a/api/package.json +++ b/api/package.json @@ -16,6 +16,7 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", + "nodemailer": "^9.0.3", "pg": "^8.22.0", "qrcode": "^1.5.4", "redis": "^6.1.0", diff --git a/api/routes/admin.js b/api/routes/admin.js index d741846..37536f8 100644 --- a/api/routes/admin.js +++ b/api/routes/admin.js @@ -63,6 +63,13 @@ router.put('/users/:id/tier', async (req, res) => { } 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( 'UPDATE users SET role = $1 WHERE id = $2 RETURNING id, email, role', [role, id] diff --git a/api/routes/auth.js b/api/routes/auth.js index 5272cc2..cf5988f 100644 --- a/api/routes/auth.js +++ b/api/routes/auth.js @@ -2,7 +2,9 @@ const express = require('express'); const router = express.Router(); const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); const { Pool } = require('pg'); +const { sendMail } = require('../utils/emailService'); const pgPool = new Pool({ host: process.env.PGHOST || 'postgres', @@ -72,7 +74,7 @@ router.post('/login', async (req, res) => { const { verifyToken } = require('../middleware/auth'); router.get('/me', verifyToken, async (req, res) => { 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' }); res.json({ user: userQuery.rows[0] }); } 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 = ` +

Verify Your Email

+

Thanks for using CK-QR! Please verify your email address to unlock Pro tier upgrades.

+ Verify Email +

Or copy and paste this link:
${verifyLink}

+ `; + + 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 router.post('/logout', (req, res) => { res.clearCookie('token'); diff --git a/api/routes/payments.js b/api/routes/payments.js index 7baa438..f8c355a 100644 --- a/api/routes/payments.js +++ b/api/routes/payments.js @@ -1,10 +1,29 @@ const express = require('express'); 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 router.post('/icici/initiate', async (req, res) => { 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. // and return the URL or parameters for the frontend to redirect the user to ICICI. diff --git a/api/utils/emailService.js b/api/utils/emailService.js new file mode 100644 index 0000000..5acf66e --- /dev/null +++ b/api/utils/emailService.js @@ -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 }; \ No newline at end of file diff --git a/client/src/app/verify-email/page.tsx b/client/src/app/verify-email/page.tsx new file mode 100644 index 0000000..208f7b8 --- /dev/null +++ b/client/src/app/verify-email/page.tsx @@ -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 ( +
+
+

+ Email Verification +

+
+ +
+
+ {status === 'loading' && ( +
+ + + + +

{message}

+
+ )} + + {status === 'success' && ( +
+
+ +
+

{message}

+

Redirecting to your dashboard...

+ + Go to Dashboard + +
+ )} + + {status === 'error' && ( +
+
+ +
+

{message}

+ + Back to Dashboard + +
+ )} +
+
+
+ ); +} + +export default function VerifyEmailPage() { + return ( + Loading...}> + + + ) +} \ No newline at end of file