feat(auth): Enforce global email verification for all users, restrict dashboard if unverified, add verification badges and banners

This commit is contained in:
Mohan Ki
2026-07-27 19:11:58 +05:30
parent 836693192e
commit 4fe25f3de5
5 changed files with 124 additions and 18 deletions
+23 -1
View File
@@ -1,4 +1,13 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
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',
});
const verifyToken = (req, res, next) => { const verifyToken = (req, res, next) => {
let token = req.headers['authorization']; let token = req.headers['authorization'];
@@ -19,4 +28,17 @@ const verifyToken = (req, res, next) => {
}); });
}; };
module.exports = { verifyToken }; const requireEmailVerification = async (req, res, next) => {
try {
const userCheck = await pgPool.query('SELECT is_email_verified FROM users WHERE id = $1', [req.user.id]);
if (userCheck.rows.length === 0 || !userCheck.rows[0].is_email_verified) {
return res.status(403).json({ error: 'Email verification required' });
}
next();
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Server error checking verification' });
}
};
module.exports = { verifyToken, requireEmailVerification };
+21 -1
View File
@@ -37,7 +37,27 @@ router.post('/register', async (req, res) => {
[email, hash, assignedRole] [email, hash, assignedRole]
); );
const token = jwt.sign({ id: newUser.rows[0].id, email: newUser.rows[0].email, role: newUser.rows[0].role }, JWT_SECRET, { expiresIn: '14d' }); const userId = newUser.rows[0].id;
const userEmail = newUser.rows[0].email;
// Auto send verification email
const tokenStr = crypto.randomBytes(32).toString('hex');
await pgPool.query('UPDATE users SET email_verification_token = $1 WHERE id = $2', [tokenStr, userId]);
const baseUrl = process.env.FRONTEND_URL || 'https://qr.houseofwebsites.ai';
const verifyLink = `${baseUrl}/verify-email?token=${tokenStr}`;
const html = `
<h2>Verify Your Email</h2>
<p>Thanks for registering for CK-QR! Please verify your email address to unlock your dashboard features.</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>
`;
// Send email asynchronously without blocking registration response
sendMail(userEmail, 'Verify your CK-QR account', html).catch(e => console.error('Failed to send auto-verify email:', e));
const token = jwt.sign({ id: userId, email: userEmail, role: newUser.rows[0].role }, JWT_SECRET, { expiresIn: '14d' });
res.cookie('token', token, { httpOnly: true, maxAge: 14 * 24 * 60 * 60 * 1000, secure: false, sameSite: 'lax' }); res.cookie('token', token, { httpOnly: true, maxAge: 14 * 24 * 60 * 60 * 1000, secure: false, sameSite: 'lax' });
res.status(201).json({ message: 'User registered', user: newUser.rows[0] }); res.status(201).json({ message: 'User registered', user: newUser.rows[0] });
+4 -4
View File
@@ -2,7 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const QRCode = require('qrcode'); const QRCode = require('qrcode');
const shortid = require('shortid'); const shortid = require('shortid');
const { verifyToken } = require('../middleware/auth'); const { verifyToken, requireEmailVerification } = require('../middleware/auth');
const { Pool } = require('pg'); const { Pool } = require('pg');
const pgPool = new Pool({ const pgPool = new Pool({
@@ -56,7 +56,7 @@ router.get('/:id', verifyToken, async (req, res) => {
}); });
// Generate QR Code // Generate QR Code
router.post('/generate', verifyToken, async (req, res) => { router.post('/generate', verifyToken, requireEmailVerification, async (req, res) => {
const { name, type, dataType, destinationUrl, designData, customSlug, folderId } = req.body; const { name, type, dataType, destinationUrl, designData, customSlug, folderId } = req.body;
const userId = req.user.id; const userId = req.user.id;
@@ -107,7 +107,7 @@ router.post('/generate', verifyToken, async (req, res) => {
}); });
// Update QR Code (Editable Link / Move Folder / Update Design) // Update QR Code (Editable Link / Move Folder / Update Design)
router.put('/:id', verifyToken, async (req, res) => { router.put('/:id', verifyToken, requireEmailVerification, async (req, res) => {
const { name, destinationUrl, shortUrlId, designData, folderId } = req.body; const { name, destinationUrl, shortUrlId, designData, folderId } = req.body;
const qrId = req.params.id; const qrId = req.params.id;
const userId = req.user.id; const userId = req.user.id;
@@ -150,7 +150,7 @@ router.put('/:id', verifyToken, async (req, res) => {
}); });
// Delete QR Code // Delete QR Code
router.delete('/:id', verifyToken, async (req, res) => { router.delete('/:id', verifyToken, requireEmailVerification, async (req, res) => {
try { try {
const result = await pgPool.query('DELETE FROM qr_codes WHERE id = $1 AND user_id = $2 RETURNING id', [req.params.id, req.user.id]); const result = await pgPool.query('DELETE FROM qr_codes WHERE id = $1 AND user_id = $2 RETURNING id', [req.params.id, req.user.id]);
if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' }); if (result.rows.length === 0) return res.status(404).json({ error: 'Not found' });
+41 -6
View File
@@ -6,6 +6,17 @@ import { usePathname } from 'next/navigation';
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [user, setUser] = useState<any>(null);
React.useEffect(() => {
import('@/lib/api').then(({ default: api }) => {
api.get('/auth/me').then(res => {
setUser(res.data.user);
}).catch(err => {
console.error('Failed to fetch user', err);
});
});
}, []);
const menuItems = [ const menuItems = [
{ name: 'My QR Codes', path: '/dashboard', icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z' }, { name: 'My QR Codes', path: '/dashboard', icon: 'M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z' },
@@ -38,10 +49,17 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</div> </div>
<div className="p-4"> <div className="p-4">
<Link href="/dashboard/create" className="flex items-center justify-center gap-2 w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-4 rounded-xl transition-colors shadow-sm mb-6"> {user?.is_email_verified === false ? (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg> <button disabled className="flex items-center justify-center gap-2 w-full bg-gray-400 text-white font-bold py-3 px-4 rounded-xl shadow-sm mb-6 cursor-not-allowed opacity-70">
Create New QR Code <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
</Link> Verify Email to Create
</button>
) : (
<Link href="/dashboard/create" className="flex items-center justify-center gap-2 w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-4 rounded-xl transition-colors shadow-sm mb-6">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
Create New QR Code
</Link>
)}
</div> </div>
<nav className="flex-1 px-4 space-y-1 overflow-y-auto"> <nav className="flex-1 px-4 space-y-1 overflow-y-auto">
@@ -75,8 +93,25 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
</aside> </aside>
{/* Main Content */} {/* Main Content */}
<main className="flex-1 h-screen overflow-y-auto relative"> <main className="flex-1 h-screen overflow-y-auto relative flex flex-col">
{children} {user?.is_email_verified === false && (
<div className="bg-red-50 border-b border-red-200 px-6 py-3 flex items-center justify-between shadow-sm z-10">
<div className="flex items-center gap-3 text-red-800">
<svg className="w-6 h-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="font-medium text-sm">
Your account is currently restricted. Please verify your email address to unlock QR Code creation.
</p>
</div>
<Link href="/dashboard/profile" className="text-sm font-bold bg-white text-red-700 hover:bg-red-100 border border-red-300 px-4 py-1.5 rounded-lg transition-colors whitespace-nowrap">
Verify Now
</Link>
</div>
)}
<div className="flex-1 overflow-y-auto">
{children}
</div>
</main> </main>
{/* Mobile overlay */} {/* Mobile overlay */}
+35 -6
View File
@@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react';
import api from '@/lib/api'; import api from '@/lib/api';
export default function ProfilePage() { export default function ProfilePage() {
const [user, setUser] = useState<{ email: string; role: string } | null>(null); const [user, setUser] = useState<{ email: string; role: string; is_email_verified: boolean } | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@@ -37,11 +37,40 @@ export default function ProfilePage() {
<div className="w-20 h-20 bg-indigo-100 text-indigo-600 rounded-full flex items-center justify-center text-3xl font-bold uppercase shadow-sm"> <div className="w-20 h-20 bg-indigo-100 text-indigo-600 rounded-full flex items-center justify-center text-3xl font-bold uppercase shadow-sm">
{user.email.charAt(0)} {user.email.charAt(0)}
</div> </div>
<div> <div className="flex-1">
<h2 className="text-2xl font-bold text-gray-900">{user.email}</h2> <div className="flex items-center gap-3">
<span className="inline-flex items-center mt-2 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider bg-indigo-50 text-indigo-700 border border-indigo-100"> <h2 className="text-2xl font-bold text-gray-900">{user.email}</h2>
{user.role.replace('_', ' ')} Plan {user.is_email_verified ? (
</span> <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" /></svg>
Verified
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Unverified
</span>
)}
</div>
<div className="mt-2 flex items-center gap-3">
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider bg-indigo-50 text-indigo-700 border border-indigo-100">
{user.role.replace('_', ' ')} Plan
</span>
{!user.is_email_verified && (
<button
onClick={async () => {
try {
await api.post('/auth/send-verification');
alert('Verification email sent! Please check your inbox.');
} catch (err: any) {
alert(err.response?.data?.error || 'Failed to send email');
}
}}
className="text-sm font-semibold text-indigo-600 hover:text-indigo-800 underline"
>
Resend Verification Email
</button>
)}
</div>
</div> </div>
</div> </div>